Add issue change history and daily issue stats
Gitea Actions Demo / Deploy (push) Successful in 32s

Issue changes are recorded per field in the issue_change table, in the same
transaction as the issue itself. The new history module exposes:
- GET /api/issues/:issueId/history — change timeline grouped by save
- GET /api/issues/stats/daily — created, status transitions and status
  snapshot per day, with time zone support

The migration backfills history for existing issues. Setting a field to its
current value no longer bumps updatedAt or emits issue.updated.
This commit is contained in:
vadim committed 2026-10-08 09:55:21 +03:00
1 parent f44590b8ca
commit cb29c7d2c4
26 files changed
+1026 -16

No files matched your search

@@ -0,0 +1,12 @@
export class GetDailyIssueStatsQuery {
constructor(
public readonly userId: string,
public readonly projectId?: string,
/** YYYY-MM-DD */
public readonly from?: string,
/** YYYY-MM-DD */
public readonly to?: string,
/** IANA, например Europe/Moscow */
public readonly timeZone?: string,
) {}
}
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IIssueHistoryRepository } from '../../../domain/repositories/issue-history.repository.interface.js';
import { InvalidStatsPeriodException } from '../../../domain/exceptions/invalid-stats-period.exception.js';
import { GetDailyIssueStatsUseCase } from './get-daily-issue-stats.use-case.js';
import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
const zero = { OPEN: 0, IN_PROGRESS: 0, DONE: 0, CANCELLED: 0 };
describe('GetDailyIssueStatsUseCase', () => {
let repository: IIssueHistoryRepository;
let useCase: GetDailyIssueStatsUseCase;
beforeEach(() => {
repository = {
findByIssue: vi.fn(),
countDailyEvents: vi.fn().mockResolvedValue([]),
countDailyStatuses: vi.fn().mockResolvedValue([]),
};
useCase = new GetDailyIssueStatsUseCase(repository);
});
it('passes the validated period and visibility params to the repository', async () => {
await useCase.execute(
new GetDailyIssueStatsQuery('user-1', 'project-1', '2026-10-01', '2026-10-03', 'Europe/Moscow'),
);
const range = {
userId: 'user-1',
projectId: 'project-1',
from: '2026-10-01',
to: '2026-10-03',
timeZone: 'Europe/Moscow',
};
expect(repository.countDailyEvents).toHaveBeenCalledWith(range);
expect(repository.countDailyStatuses).toHaveBeenCalledWith(range);
});
it('fills every day and every status, including the empty ones', async () => {
vi.mocked(repository.countDailyEvents).mockResolvedValue([
{ date: '2026-10-01', kind: 'created', count: 2 },
{ date: '2026-10-03', kind: 'status', status: 'DONE', count: 1 },
]);
vi.mocked(repository.countDailyStatuses).mockResolvedValue([
{ date: '2026-10-01', status: 'OPEN', count: 2 },
{ date: '2026-10-03', status: 'OPEN', count: 1 },
{ date: '2026-10-03', status: 'DONE', count: 1 },
]);
const result = await useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-01', '2026-10-03'));
expect(result).toEqual({
from: '2026-10-01',
to: '2026-10-03',
timeZone: 'UTC',
days: [
{ date: '2026-10-01', created: 2, transitions: zero, byStatus: { ...zero, OPEN: 2 } },
{ date: '2026-10-02', created: 0, transitions: zero, byStatus: zero },
{ date: '2026-10-03', created: 0, transitions: { ...zero, DONE: 1 }, byStatus: { ...zero, OPEN: 1, DONE: 1 } },
],
});
});
it('ignores statuses that are no longer known', async () => {
vi.mocked(repository.countDailyStatuses).mockResolvedValue([{ date: '2026-10-01', status: 'LEGACY', count: 5 }]);
const result = await useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-01', '2026-10-01'));
expect(result.days[0]!.byStatus).toEqual(zero);
});
it('rejects an invalid period without querying the database', async () => {
await expect(
useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-05', '2026-10-01')),
).rejects.toThrow(InvalidStatsPeriodException);
expect(repository.countDailyEvents).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,73 @@
import { Inject, Injectable } from '@nestjs/common';
import { ISSUE_STATUSES, type IssueStatusValue } from '../../../../issue/domain/value-objects/issue-status.vo.js';
import {
ISSUE_HISTORY_REPOSITORY,
type IIssueHistoryRepository,
} from '../../../domain/repositories/issue-history.repository.interface.js';
import { StatsPeriod } from '../../../domain/value-objects/stats-period.vo.js';
import { DailyIssueStatsDayDto, DailyIssueStatsDto, IssueStatusCounts } from '../../dto/daily-issue-stats.dto.js';
import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
/**
* Статистика задач по дням, считается по истории изменений (issue_change).
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee.
*/
@Injectable()
export class GetDailyIssueStatsUseCase {
constructor(
@Inject(ISSUE_HISTORY_REPOSITORY)
private readonly historyRepository: IIssueHistoryRepository,
) {}
async execute(query: GetDailyIssueStatsQuery): Promise<DailyIssueStatsDto> {
const period = StatsPeriod.create({ from: query.from, to: query.to, timeZone: query.timeZone });
const range = {
userId: query.userId,
projectId: query.projectId,
from: period.from,
to: period.to,
timeZone: period.timeZone,
};
const [events, statuses] = await Promise.all([
this.historyRepository.countDailyEvents(range),
this.historyRepository.countDailyStatuses(range),
]);
// Каждый день периода попадает в ответ, даже если в нём ничего не было
const days = new Map<string, DailyIssueStatsDayDto>(
period.days.map((date) => [
date,
{ date, created: 0, transitions: emptyStatusCounts(), byStatus: emptyStatusCounts() },
]),
);
for (const event of events) {
const day = days.get(event.date);
if (!day) continue;
if (event.kind === 'created') {
day.created += event.count;
} else if (isKnownStatus(event.status)) {
day.transitions[event.status] += event.count;
}
}
for (const entry of statuses) {
const day = days.get(entry.date);
if (day && isKnownStatus(entry.status)) {
day.byStatus[entry.status] += entry.count;
}
}
return { from: period.from, to: period.to, timeZone: period.timeZone, days: [...days.values()] };
}
}
function emptyStatusCounts(): IssueStatusCounts {
return Object.fromEntries(ISSUE_STATUSES.map((status) => [status.alias, 0])) as IssueStatusCounts;
}
function isKnownStatus(status: string): status is IssueStatusValue {
return ISSUE_STATUSES.some((known) => known.alias === status);
}