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:
1 parent
f44590b8ca
commit
cb29c7d2c4
26 files changed
+1026
-16
No files matched your search
+12
@@ -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,
|
||||
) {}
|
||||
}
|
||||
+77
@@ -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();
|
||||
});
|
||||
});
|
||||
+73
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user