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
+1025
-15
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "issue_change" (
|
||||||
|
"id" SERIAL NOT NULL,
|
||||||
|
"issueId" INTEGER NOT NULL,
|
||||||
|
"changeId" TEXT NOT NULL,
|
||||||
|
"field" TEXT NOT NULL,
|
||||||
|
"oldValue" TEXT,
|
||||||
|
"newValue" TEXT,
|
||||||
|
"actorId" TEXT,
|
||||||
|
"changedAt" TIMESTAMPTZ(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "issue_change_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_change_issueId_changedAt_idx" ON "issue_change"("issueId", "changedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_change_field_changedAt_idx" ON "issue_change"("field", "changedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "issue_change_actorId_idx" ON "issue_change"("actorId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_change" ADD CONSTRAINT "issue_change_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "issue_change" ADD CONSTRAINT "issue_change_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "user"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
@@ -26,6 +26,7 @@ model User {
|
|||||||
notificationMessages NotificationMessage[]
|
notificationMessages NotificationMessage[]
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
uploadedFiles File[]
|
uploadedFiles File[]
|
||||||
|
issueChanges IssueChange[]
|
||||||
|
|
||||||
@@unique([email])
|
@@unique([email])
|
||||||
@@map("user")
|
@@map("user")
|
||||||
@@ -96,6 +97,7 @@ model Issue {
|
|||||||
dueDate DateTime? @db.Timestamptz(3)
|
dueDate DateTime? @db.Timestamptz(3)
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
attachments IssueAttachment[]
|
attachments IssueAttachment[]
|
||||||
|
changes IssueChange[]
|
||||||
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
createdAt DateTime @default(now()) @db.Timestamptz(3)
|
||||||
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
updatedAt DateTime @updatedAt @db.Timestamptz(3)
|
||||||
|
|
||||||
@@ -264,3 +266,29 @@ model CommentAttachment {
|
|||||||
@@index([fileId])
|
@@index([fileId])
|
||||||
@@map("comment_attachment")
|
@@map("comment_attachment")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// История изменений задачи — одна строка на изменённое поле.
|
||||||
|
/// Строки одного сохранения задачи объединены общим changeId.
|
||||||
|
/// field: created | title | content | status | priority | assignee | dueDate
|
||||||
|
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
|
||||||
|
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
||||||
|
/// При создании задачи пишутся две строки: created и status null -> OPEN,
|
||||||
|
/// поэтому статус задачи на любой момент восстанавливается по строкам status.
|
||||||
|
/// У задач, созданных до появления истории, строк нет — в статистику они не попадают.
|
||||||
|
model IssueChange {
|
||||||
|
id Int @id @default(autoincrement())
|
||||||
|
issueId Int
|
||||||
|
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
|
||||||
|
changeId String
|
||||||
|
field String
|
||||||
|
oldValue String?
|
||||||
|
newValue String?
|
||||||
|
actorId String?
|
||||||
|
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
|
||||||
|
changedAt DateTime @db.Timestamptz(3)
|
||||||
|
|
||||||
|
@@index([issueId, changedAt])
|
||||||
|
@@index([field, changedAt])
|
||||||
|
@@index([actorId])
|
||||||
|
@@map("issue_change")
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { NotificationModule } from './modules/notification/notification.module.j
|
|||||||
import { CommentModule } from './modules/comment/comment.module.js';
|
import { CommentModule } from './modules/comment/comment.module.js';
|
||||||
import { FileModule } from './modules/file/file.module.js';
|
import { FileModule } from './modules/file/file.module.js';
|
||||||
import { IssueAttachmentModule } from './modules/issue-attachment/issue-attachment.module.js';
|
import { IssueAttachmentModule } from './modules/issue-attachment/issue-attachment.module.js';
|
||||||
|
import { HistoryModule } from './modules/history/history.module.js';
|
||||||
import { AuthModule } from '@thallesp/nestjs-better-auth';
|
import { AuthModule } from '@thallesp/nestjs-better-auth';
|
||||||
import { auth } from './lib/auth.js';
|
import { auth } from './lib/auth.js';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
@@ -55,6 +56,7 @@ import { join } from 'node:path';
|
|||||||
CommentModule,
|
CommentModule,
|
||||||
FileModule,
|
FileModule,
|
||||||
IssueAttachmentModule,
|
IssueAttachmentModule,
|
||||||
|
HistoryModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { IssueStatusValue } from '../../../issue/domain/value-objects/issue-status.vo.js';
|
||||||
|
|
||||||
|
/** Счётчик по каждому статусу; статусы без задач — 0 */
|
||||||
|
export type IssueStatusCounts = Record<IssueStatusValue, number>;
|
||||||
|
|
||||||
|
export class DailyIssueStatsDayDto {
|
||||||
|
/** YYYY-MM-DD в часовом поясе запроса */
|
||||||
|
date: string;
|
||||||
|
/** Создано задач за день */
|
||||||
|
created: number;
|
||||||
|
/** Переходов в статус за день (создание задачи в OPEN сюда не входит). transitions.DONE — выполнено за день */
|
||||||
|
transitions: IssueStatusCounts;
|
||||||
|
/** Задач в каждом статусе на конец дня */
|
||||||
|
byStatus: IssueStatusCounts;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DailyIssueStatsDto {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
timeZone: string;
|
||||||
|
days: DailyIssueStatsDayDto[];
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { IssueChangeField } from '../../../issue/domain/entities/issue-change.js';
|
||||||
|
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||||
|
|
||||||
|
export class IssueFieldChangeDto {
|
||||||
|
field: IssueChangeField;
|
||||||
|
oldValue: string | null;
|
||||||
|
newValue: string | null;
|
||||||
|
/** Только для field = assignee: пользователи из oldValue/newValue (null — не назначен или удалён) */
|
||||||
|
oldUser?: UserDto | null;
|
||||||
|
newUser?: UserDto | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Одно сохранение задачи: кто, когда и какие поля изменил */
|
||||||
|
export class IssueHistoryEntryDto {
|
||||||
|
/** changeId */
|
||||||
|
id: string;
|
||||||
|
changedAt: Date;
|
||||||
|
actorId: string | null;
|
||||||
|
actor: UserDto | null;
|
||||||
|
changes: IssueFieldChangeDto[];
|
||||||
|
}
|
||||||
+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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class GetIssueHistoryQuery {
|
||||||
|
constructor(
|
||||||
|
public readonly issueId: number,
|
||||||
|
public readonly userId: string,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
+103
@@ -0,0 +1,103 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import type {
|
||||||
|
IIssueHistoryRepository,
|
||||||
|
IssueChangeRecord,
|
||||||
|
} from '../../../domain/repositories/issue-history.repository.interface.js';
|
||||||
|
import type { IIssueRepository } from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||||
|
import { Issue } from '../../../../issue/domain/entities/issue.entity.js';
|
||||||
|
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import type { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||||
|
import type { UserDto } from '../../../../user/application/dto/user.dto.js';
|
||||||
|
import { GetIssueHistoryUseCase } from './get-issue-history.use-case.js';
|
||||||
|
import { GetIssueHistoryQuery } from './get-issue-history.query.js';
|
||||||
|
|
||||||
|
const buildIssue = () =>
|
||||||
|
Issue.reconstitute({
|
||||||
|
id: 1,
|
||||||
|
key: 'ISSUE-1',
|
||||||
|
title: 'Title',
|
||||||
|
content: null,
|
||||||
|
status: 'OPEN',
|
||||||
|
priority: 0,
|
||||||
|
assignee: null,
|
||||||
|
reporter: 'user-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
dueDate: null,
|
||||||
|
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const user = (id: string) => ({ id, name: id }) as UserDto;
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
const record = (
|
||||||
|
props: Partial<IssueChangeRecord> & Pick<IssueChangeRecord, 'changeId' | 'field'>,
|
||||||
|
): IssueChangeRecord => ({
|
||||||
|
id: nextId++,
|
||||||
|
issueId: 1,
|
||||||
|
oldValue: null,
|
||||||
|
newValue: null,
|
||||||
|
actorId: 'user-1',
|
||||||
|
changedAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('GetIssueHistoryUseCase', () => {
|
||||||
|
let historyRepository: IIssueHistoryRepository;
|
||||||
|
let issueRepository: IIssueRepository;
|
||||||
|
let getUsersUseCase: GetUsersUseCase;
|
||||||
|
let useCase: GetIssueHistoryUseCase;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
historyRepository = { findByIssue: vi.fn(), countDailyEvents: vi.fn(), countDailyStatuses: vi.fn() };
|
||||||
|
issueRepository = {
|
||||||
|
findById: vi.fn(),
|
||||||
|
findByCKey: vi.fn(),
|
||||||
|
findAll: vi.fn(),
|
||||||
|
countByStatus: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn(),
|
||||||
|
};
|
||||||
|
getUsersUseCase = {
|
||||||
|
execute: vi.fn(async (query: { ids: string[] }) => query.ids.filter((id) => id !== 'deleted').map(user)),
|
||||||
|
} as unknown as GetUsersUseCase;
|
||||||
|
useCase = new GetIssueHistoryUseCase(historyRepository, issueRepository, getUsersUseCase);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws IssueNotFoundException when the issue is not visible to the user', async () => {
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(null);
|
||||||
|
|
||||||
|
await expect(useCase.execute(new GetIssueHistoryQuery(1, 'user-9'))).rejects.toThrow(IssueNotFoundException);
|
||||||
|
expect(issueRepository.findById).toHaveBeenCalledWith(1, 'user-9');
|
||||||
|
expect(historyRepository.findByIssue).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('groups rows of one save into an entry and expands actors and assignees', async () => {
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||||
|
vi.mocked(historyRepository.findByIssue).mockResolvedValue([
|
||||||
|
record({ changeId: 'c1', field: 'created' }),
|
||||||
|
record({ changeId: 'c1', field: 'status', newValue: 'OPEN' }),
|
||||||
|
record({ changeId: 'c2', field: 'status', oldValue: 'OPEN', newValue: 'DONE', actorId: 'user-2' }),
|
||||||
|
record({ changeId: 'c2', field: 'assignee', oldValue: 'deleted', newValue: 'user-3', actorId: 'user-2' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const entries = await useCase.execute(new GetIssueHistoryQuery(1, 'user-1'));
|
||||||
|
|
||||||
|
expect(entries.map((entry) => [entry.id, entry.actor?.id, entry.changes.map((change) => change.field)])).toEqual([
|
||||||
|
['c1', 'user-1', ['created', 'status']],
|
||||||
|
['c2', 'user-2', ['status', 'assignee']],
|
||||||
|
]);
|
||||||
|
expect(entries[1]!.changes[0]).toEqual({ field: 'status', oldValue: 'OPEN', newValue: 'DONE' });
|
||||||
|
expect(entries[1]!.changes[1]).toMatchObject({ oldUser: null, newUser: { id: 'user-3' } });
|
||||||
|
expect(getUsersUseCase.execute).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not query users for an empty history', async () => {
|
||||||
|
vi.mocked(issueRepository.findById).mockResolvedValue(buildIssue());
|
||||||
|
vi.mocked(historyRepository.findByIssue).mockResolvedValue([]);
|
||||||
|
|
||||||
|
expect(await useCase.execute(new GetIssueHistoryQuery(1, 'user-1'))).toEqual([]);
|
||||||
|
expect(getUsersUseCase.execute).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ISSUE_REPOSITORY,
|
||||||
|
type IIssueRepository,
|
||||||
|
} from '../../../../issue/domain/repositories/issue.repository.interface.js';
|
||||||
|
import { IssueNotFoundException } from '../../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||||
|
import { GetUsersQuery } from '../../../../user/application/use-cases/get-users/get-users.query.js';
|
||||||
|
import type { UserDto } from '../../../../user/application/dto/user.dto.js';
|
||||||
|
import {
|
||||||
|
ISSUE_HISTORY_REPOSITORY,
|
||||||
|
type IIssueHistoryRepository,
|
||||||
|
type IssueChangeRecord,
|
||||||
|
} from '../../../domain/repositories/issue-history.repository.interface.js';
|
||||||
|
import { IssueFieldChangeDto, IssueHistoryEntryDto } from '../../dto/issue-history.dto.js';
|
||||||
|
import { GetIssueHistoryQuery } from './get-issue-history.query.js';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GetIssueHistoryUseCase {
|
||||||
|
constructor(
|
||||||
|
@Inject(ISSUE_HISTORY_REPOSITORY)
|
||||||
|
private readonly historyRepository: IIssueHistoryRepository,
|
||||||
|
@Inject(ISSUE_REPOSITORY)
|
||||||
|
private readonly issueRepository: IIssueRepository,
|
||||||
|
private readonly getUsersUseCase: GetUsersUseCase,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetIssueHistoryQuery): Promise<IssueHistoryEntryDto[]> {
|
||||||
|
const issue = await this.issueRepository.findById(query.issueId, query.userId);
|
||||||
|
if (!issue) {
|
||||||
|
throw new IssueNotFoundException(query.issueId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const records = await this.historyRepository.findByIssue(query.issueId);
|
||||||
|
const usersById = await this.loadUsers(records);
|
||||||
|
const userOf = (id: string | null) => (id ? (usersById.get(id) ?? null) : null);
|
||||||
|
|
||||||
|
// Строки одного сохранения объединяются по changeId, порядок — по первой строке
|
||||||
|
const entries = new Map<string, IssueHistoryEntryDto>();
|
||||||
|
for (const record of records) {
|
||||||
|
let entry = entries.get(record.changeId);
|
||||||
|
if (!entry) {
|
||||||
|
entry = {
|
||||||
|
id: record.changeId,
|
||||||
|
changedAt: record.changedAt,
|
||||||
|
actorId: record.actorId,
|
||||||
|
actor: userOf(record.actorId),
|
||||||
|
changes: [],
|
||||||
|
};
|
||||||
|
entries.set(record.changeId, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const change: IssueFieldChangeDto = {
|
||||||
|
field: record.field,
|
||||||
|
oldValue: record.oldValue,
|
||||||
|
newValue: record.newValue,
|
||||||
|
};
|
||||||
|
if (record.field === 'assignee') {
|
||||||
|
change.oldUser = userOf(record.oldValue);
|
||||||
|
change.newUser = userOf(record.newValue);
|
||||||
|
}
|
||||||
|
entry.changes.push(change);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...entries.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Авторы изменений и назначенные/снятые исполнители */
|
||||||
|
private async loadUsers(records: IssueChangeRecord[]): Promise<Map<string, UserDto>> {
|
||||||
|
const ids = new Set<string>();
|
||||||
|
for (const record of records) {
|
||||||
|
if (record.actorId) ids.add(record.actorId);
|
||||||
|
if (record.field === 'assignee') {
|
||||||
|
if (record.oldValue) ids.add(record.oldValue);
|
||||||
|
if (record.newValue) ids.add(record.newValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (ids.size === 0) return new Map();
|
||||||
|
|
||||||
|
const users = await this.getUsersUseCase.execute(new GetUsersQuery([...ids]));
|
||||||
|
return new Map(users.map((user) => [user.id, user]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class InvalidStatsPeriodException extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'InvalidStatsPeriodException';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { IssueChangeField } from '../../../issue/domain/entities/issue-change.js';
|
||||||
|
|
||||||
|
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
|
||||||
|
export const ISSUE_HISTORY_REPOSITORY = Symbol('ISSUE_HISTORY_REPOSITORY');
|
||||||
|
|
||||||
|
/** Строка истории: изменение одного поля задачи (см. модель IssueChange) */
|
||||||
|
export interface IssueChangeRecord {
|
||||||
|
id: number;
|
||||||
|
issueId: number;
|
||||||
|
/** Общий для всех полей, изменённых одним сохранением задачи */
|
||||||
|
changeId: string;
|
||||||
|
field: IssueChangeField;
|
||||||
|
oldValue: string | null;
|
||||||
|
newValue: string | null;
|
||||||
|
actorId: string | null;
|
||||||
|
changedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Период статистики: даты YYYY-MM-DD включительно, границы дней — в timeZone */
|
||||||
|
export interface DailyStatsRange {
|
||||||
|
userId: string;
|
||||||
|
projectId?: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
timeZone: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** created — задачи, созданные за день; status — переходы в status за день (без начального OPEN) */
|
||||||
|
export type DailyEventCount =
|
||||||
|
{ date: string; kind: 'created'; count: number } | { date: string; kind: 'status'; status: string; count: number };
|
||||||
|
|
||||||
|
/** Сколько задач было в статусе на конец дня */
|
||||||
|
export interface DailyStatusCount {
|
||||||
|
date: string;
|
||||||
|
status: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IIssueHistoryRepository {
|
||||||
|
/** История задачи от старых изменений к новым */
|
||||||
|
findByIssue(issueId: number): Promise<IssueChangeRecord[]>;
|
||||||
|
/** Только задачи, видимые пользователю (reporter/assignee) */
|
||||||
|
countDailyEvents(range: DailyStatsRange): Promise<DailyEventCount[]>;
|
||||||
|
/** Только задачи, видимые пользователю (reporter/assignee) */
|
||||||
|
countDailyStatuses(range: DailyStatsRange): Promise<DailyStatusCount[]>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { StatsPeriod } from './stats-period.vo.js';
|
||||||
|
import { InvalidStatsPeriodException } from '../exceptions/invalid-stats-period.exception.js';
|
||||||
|
|
||||||
|
describe('StatsPeriod', () => {
|
||||||
|
const now = new Date('2026-10-07T22:30:00.000Z');
|
||||||
|
|
||||||
|
it('defaults to the last 30 days up to today in UTC', () => {
|
||||||
|
const period = StatsPeriod.create({}, now);
|
||||||
|
|
||||||
|
expect(period.from).toBe('2026-09-08');
|
||||||
|
expect(period.to).toBe('2026-10-07');
|
||||||
|
expect(period.timeZone).toBe('UTC');
|
||||||
|
expect(period.days).toHaveLength(30);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('takes "today" in the requested time zone', () => {
|
||||||
|
// 22:30 UTC — в Москве уже 8 октября
|
||||||
|
expect(StatsPeriod.create({ timeZone: 'Europe/Moscow' }, now).to).toBe('2026-10-08');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('lists every day of an explicit period inclusively', () => {
|
||||||
|
const period = StatsPeriod.create({ from: '2026-02-27', to: '2026-03-02' }, now);
|
||||||
|
|
||||||
|
expect(period.days).toEqual(['2026-02-27', '2026-02-28', '2026-03-01', '2026-03-02']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ from: '2026-10-05', to: '2026-10-01' }, 'from after to'],
|
||||||
|
[{ from: '2026-02-30' }, 'non-existent date'],
|
||||||
|
[{ to: '07.10.2026' }, 'wrong date format'],
|
||||||
|
[{ from: '2025-01-01', to: '2026-10-07' }, 'period too long'],
|
||||||
|
[{ timeZone: 'Mars/Olympus' }, 'unknown time zone'],
|
||||||
|
[{ timeZone: '+03:00' }, 'offset instead of IANA name'],
|
||||||
|
] as const)('rejects %j (%s)', (params, _reason) => {
|
||||||
|
expect(() => StatsPeriod.create(params, now)).toThrow(InvalidStatsPeriodException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts the maximum period length', () => {
|
||||||
|
expect(StatsPeriod.create({ from: '2025-10-07', to: '2026-10-07' }, now).days).toHaveLength(StatsPeriod.MAX_DAYS);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { InvalidStatsPeriodException } from '../exceptions/invalid-stats-period.exception.js';
|
||||||
|
|
||||||
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
/**
|
||||||
|
* Только IANA-имена (Europe/Moscow, UTC). Смещения вида "+03:00" Intl понимает,
|
||||||
|
* а Postgres в AT TIME ZONE трактует по POSIX — с обратным знаком.
|
||||||
|
*/
|
||||||
|
const TIME_ZONE_PATTERN = /^[A-Za-z][A-Za-z0-9_+-]*(\/[A-Za-z0-9_+-]+)*$/;
|
||||||
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Период дневной статистики: from..to включительно (YYYY-MM-DD),
|
||||||
|
* границы дней считаются в часовом поясе timeZone.
|
||||||
|
*/
|
||||||
|
export class StatsPeriod {
|
||||||
|
static readonly MAX_DAYS = 366;
|
||||||
|
static readonly DEFAULT_DAYS = 30;
|
||||||
|
|
||||||
|
private constructor(
|
||||||
|
public readonly from: string,
|
||||||
|
public readonly to: string,
|
||||||
|
public readonly timeZone: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Без to — сегодня в timeZone, без from — DEFAULT_DAYS дней до to.
|
||||||
|
* Без timeZone — UTC.
|
||||||
|
*/
|
||||||
|
static create(params: { from?: string; to?: string; timeZone?: string }, now: Date = new Date()): StatsPeriod {
|
||||||
|
const timeZone = params.timeZone ?? 'UTC';
|
||||||
|
if (!isValidTimeZone(timeZone)) {
|
||||||
|
throw new InvalidStatsPeriodException(`Неизвестный часовой пояс: "${timeZone}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = params.to !== undefined ? parseDate(params.to, 'to') : parseDate(todayIn(timeZone, now), 'to');
|
||||||
|
const from =
|
||||||
|
params.from !== undefined ? parseDate(params.from, 'from') : to - (StatsPeriod.DEFAULT_DAYS - 1) * DAY_MS;
|
||||||
|
|
||||||
|
if (from > to) {
|
||||||
|
throw new InvalidStatsPeriodException('Дата from позже даты to');
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = (to - from) / DAY_MS + 1;
|
||||||
|
if (days > StatsPeriod.MAX_DAYS) {
|
||||||
|
throw new InvalidStatsPeriodException(`Период больше ${StatsPeriod.MAX_DAYS} дней`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StatsPeriod(formatDate(from), formatDate(to), timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Все дни периода по порядку, YYYY-MM-DD */
|
||||||
|
get days(): string[] {
|
||||||
|
const days: string[] = [];
|
||||||
|
for (let day = Date.parse(this.from); day <= Date.parse(this.to); day += DAY_MS) {
|
||||||
|
days.push(formatDate(day));
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidTimeZone(timeZone: string): boolean {
|
||||||
|
if (!TIME_ZONE_PATTERN.test(timeZone)) return false;
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat('en-US', { timeZone });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** YYYY-MM-DD → полночь UTC в мс; несуществующие даты (02-30) отклоняются */
|
||||||
|
function parseDate(value: string, name: string): number {
|
||||||
|
const time = DATE_PATTERN.test(value) ? Date.parse(value) : NaN;
|
||||||
|
if (Number.isNaN(time) || formatDate(time) !== value) {
|
||||||
|
throw new InvalidStatsPeriodException(`Некорректная дата ${name}: "${value}", ожидается YYYY-MM-DD`);
|
||||||
|
}
|
||||||
|
return time;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(time: number): string {
|
||||||
|
return new Date(time).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayIn(timeZone: string, now: Date): string {
|
||||||
|
const parts = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone,
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
}).formatToParts(now);
|
||||||
|
const part = (type: Intl.DateTimeFormatPartTypes) => parts.find((p) => p.type === type)?.value;
|
||||||
|
return `${part('year')}-${part('month')}-${part('day')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
|
||||||
|
import { IssueModule } from '../issue/issue.module.js';
|
||||||
|
import { UserModule } from '../user/user.module.js';
|
||||||
|
import { ISSUE_HISTORY_REPOSITORY } from './domain/repositories/issue-history.repository.interface.js';
|
||||||
|
import { IssueHistoryPrismaRepository } from './infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.js';
|
||||||
|
import { HistoryController } from './presentation/controllers/history.controller.js';
|
||||||
|
import { GetIssueHistoryUseCase } from './application/use-cases/get-issue-history/get-issue-history.use-case.js';
|
||||||
|
import { GetDailyIssueStatsUseCase } from './application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Чтение истории изменений задач и статистика по ней. Пишет историю
|
||||||
|
* issue-модуль (IssuePrismaRepository) — в одной транзакции с задачей;
|
||||||
|
* этот модуль от issue зависит, issue о нём не знает.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule, IssueModule, UserModule],
|
||||||
|
controllers: [HistoryController],
|
||||||
|
providers: [
|
||||||
|
GetIssueHistoryUseCase,
|
||||||
|
GetDailyIssueStatsUseCase,
|
||||||
|
{
|
||||||
|
// Domain видит только интерфейс IIssueHistoryRepository,
|
||||||
|
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
|
||||||
|
provide: ISSUE_HISTORY_REPOSITORY,
|
||||||
|
useClass: IssueHistoryPrismaRepository,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class HistoryModule {}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
||||||
|
import { Prisma } from '../../../../../../generated/prisma/client.js';
|
||||||
|
import type { IssueChangeField } from '../../../../../issue/domain/entities/issue-change.js';
|
||||||
|
import type {
|
||||||
|
DailyEventCount,
|
||||||
|
DailyStatsRange,
|
||||||
|
DailyStatusCount,
|
||||||
|
IIssueHistoryRepository,
|
||||||
|
IssueChangeRecord,
|
||||||
|
} from '../../../../domain/repositories/issue-history.repository.interface.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Задачи, видимые пользователю: reporter или assignee — то же правило, что
|
||||||
|
* ownedByUser в IssuePrismaRepository. Возвращает CTE "visible" (id задач).
|
||||||
|
*/
|
||||||
|
function visibleIssuesCte(range: DailyStatsRange): Prisma.Sql {
|
||||||
|
return Prisma.sql`"visible" AS (
|
||||||
|
SELECT "id" FROM "issue"
|
||||||
|
WHERE ("assigneeId" = ${range.userId} OR "reporterId" = ${range.userId})
|
||||||
|
${range.projectId ? Prisma.sql`AND "projectId" = ${range.projectId}` : Prisma.empty}
|
||||||
|
)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class IssueHistoryPrismaRepository implements IIssueHistoryRepository {
|
||||||
|
constructor(private readonly prisma: PrismaAdapter) {}
|
||||||
|
|
||||||
|
async findByIssue(issueId: number): Promise<IssueChangeRecord[]> {
|
||||||
|
const records = await this.prisma.issueChange.findMany({
|
||||||
|
where: { issueId },
|
||||||
|
orderBy: [{ changedAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return records.map((record) => ({ ...record, field: record.field as IssueChangeField }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async countDailyEvents(range: DailyStatsRange): Promise<DailyEventCount[]> {
|
||||||
|
const rows = await this.prisma.$queryRaw<
|
||||||
|
Array<{ date: string; field: string; status: string | null; count: number }>
|
||||||
|
>`
|
||||||
|
WITH ${visibleIssuesCte(range)}
|
||||||
|
SELECT to_char((c."changedAt" AT TIME ZONE ${range.timeZone})::date, 'YYYY-MM-DD') AS "date",
|
||||||
|
c."field",
|
||||||
|
c."newValue" AS "status",
|
||||||
|
count(*)::int AS "count"
|
||||||
|
FROM "issue_change" c
|
||||||
|
JOIN "visible" v ON v."id" = c."issueId"
|
||||||
|
WHERE (c."field" = 'created' OR (c."field" = 'status' AND c."oldValue" IS NOT NULL))
|
||||||
|
AND c."changedAt" >= (${range.from}::date)::timestamp AT TIME ZONE ${range.timeZone}
|
||||||
|
AND c."changedAt" < (${range.to}::date + 1)::timestamp AT TIME ZONE ${range.timeZone}
|
||||||
|
GROUP BY 1, 2, 3
|
||||||
|
`;
|
||||||
|
|
||||||
|
return rows.map((row) =>
|
||||||
|
row.field === 'created'
|
||||||
|
? { date: row.date, kind: 'created', count: row.count }
|
||||||
|
: { date: row.date, kind: 'status', status: row.status as string, count: row.count },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статус задачи на конец дня — последний переход строго до полуночи
|
||||||
|
* следующего дня. Переходы превращаются в интервалы [validFrom, validTo),
|
||||||
|
* день засчитывается интервалу, который покрывает его конец.
|
||||||
|
*/
|
||||||
|
async countDailyStatuses(range: DailyStatsRange): Promise<DailyStatusCount[]> {
|
||||||
|
return this.prisma.$queryRaw<DailyStatusCount[]>`
|
||||||
|
WITH ${visibleIssuesCte(range)},
|
||||||
|
"days" AS (
|
||||||
|
SELECT d::date AS "day",
|
||||||
|
(d::date + 1)::timestamp AT TIME ZONE ${range.timeZone} AS "dayEnd"
|
||||||
|
FROM generate_series(${range.from}::date, ${range.to}::date, interval '1 day') AS d
|
||||||
|
),
|
||||||
|
"intervals" AS (
|
||||||
|
SELECT c."newValue" AS "status",
|
||||||
|
c."changedAt" AS "validFrom",
|
||||||
|
lead(c."changedAt") OVER (PARTITION BY c."issueId" ORDER BY c."changedAt", c."id") AS "validTo"
|
||||||
|
FROM "issue_change" c
|
||||||
|
JOIN "visible" v ON v."id" = c."issueId"
|
||||||
|
WHERE c."field" = 'status'
|
||||||
|
)
|
||||||
|
SELECT to_char(d."day", 'YYYY-MM-DD') AS "date", i."status", count(*)::int AS "count"
|
||||||
|
FROM "days" d
|
||||||
|
JOIN "intervals" i ON i."validFrom" < d."dayEnd" AND (i."validTo" IS NULL OR i."validTo" >= d."dayEnd")
|
||||||
|
GROUP BY d."day", i."status"
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { BadRequestException, Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||||
|
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
|
||||||
|
import { GetIssueHistoryUseCase } from '../../application/use-cases/get-issue-history/get-issue-history.use-case.js';
|
||||||
|
import { GetIssueHistoryQuery } from '../../application/use-cases/get-issue-history/get-issue-history.query.js';
|
||||||
|
import { GetDailyIssueStatsUseCase } from '../../application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.js';
|
||||||
|
import { GetDailyIssueStatsQuery } from '../../application/use-cases/get-daily-issue-stats/get-daily-issue-stats.query.js';
|
||||||
|
import { DailyIssueStatsQueryDto } from '../dto/daily-issue-stats-query.dto.js';
|
||||||
|
import { IssueNotFoundException } from '../../../issue/domain/exceptions/issue-not-found.exception.js';
|
||||||
|
import { InvalidStatsPeriodException } from '../../domain/exceptions/invalid-stats-period.exception.js';
|
||||||
|
|
||||||
|
@Controller('issues')
|
||||||
|
export class HistoryController {
|
||||||
|
constructor(
|
||||||
|
private readonly getIssueHistoryUseCase: GetIssueHistoryUseCase,
|
||||||
|
private readonly getDailyIssueStatsUseCase: GetDailyIssueStatsUseCase,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Статистика по дням: создано, переходы между статусами и срез статусов на конец дня */
|
||||||
|
@Get('stats/daily')
|
||||||
|
dailyStats(@Query() query: DailyIssueStatsQueryDto, @Session() session: UserSession) {
|
||||||
|
return this.handleErrors(() =>
|
||||||
|
this.getDailyIssueStatsUseCase.execute(
|
||||||
|
new GetDailyIssueStatsQuery(session.user.id, query.projectId, query.from, query.to, query.timeZone),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** История изменений задачи от старых к новым, одна запись на сохранение */
|
||||||
|
@Get(':issueId/history')
|
||||||
|
history(@Param('issueId', ParseIntPipe) issueId: number, @Session() session: UserSession) {
|
||||||
|
return this.handleErrors(() =>
|
||||||
|
this.getIssueHistoryUseCase.execute(new GetIssueHistoryQuery(issueId, session.user.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
|
||||||
|
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof IssueNotFoundException) {
|
||||||
|
throw new NotFoundException(error.message);
|
||||||
|
}
|
||||||
|
if (error instanceof InvalidStatsPeriodException) {
|
||||||
|
throw new BadRequestException(error.message);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { StringDecorator } from '../../../../common/decorators/validators.js';
|
||||||
|
|
||||||
|
export class DailyIssueStatsQueryDto {
|
||||||
|
@StringDecorator({ isOptional: true })
|
||||||
|
projectId?: string;
|
||||||
|
|
||||||
|
/** YYYY-MM-DD, по умолчанию — 30 дней до to */
|
||||||
|
@StringDecorator({ isOptional: true })
|
||||||
|
from?: string;
|
||||||
|
|
||||||
|
/** YYYY-MM-DD включительно, по умолчанию — сегодня в timeZone */
|
||||||
|
@StringDecorator({ isOptional: true })
|
||||||
|
to?: string;
|
||||||
|
|
||||||
|
/** IANA-имя, например Europe/Moscow; по умолчанию UTC */
|
||||||
|
@StringDecorator({ isOptional: true })
|
||||||
|
timeZone?: string;
|
||||||
|
}
|
||||||
@@ -72,6 +72,14 @@ describe('UpdateIssueUseCase', () => {
|
|||||||
expect(repository.update).toHaveBeenCalledTimes(1);
|
expect(repository.update).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('passes the acting user to the repository as the author of the changes', async () => {
|
||||||
|
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||||
|
|
||||||
|
await useCase.execute(new UpdateIssueCommand(1, 'user-7', undefined, undefined, 'DONE'));
|
||||||
|
|
||||||
|
expect(repository.update).toHaveBeenCalledWith(expect.any(Issue), 'user-7');
|
||||||
|
});
|
||||||
|
|
||||||
it('assigns and unassigns depending on the assignee value', async () => {
|
it('assigns and unassigns depending on the assignee value', async () => {
|
||||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export class UpdateIssueUseCase {
|
|||||||
issue.updateDueDate(command.dueDate);
|
issue.updateDueDate(command.dueDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.issueRepository.update(issue);
|
await this.issueRepository.update(issue, command.userId);
|
||||||
const dto = await this.issueUserExpander.expand(issue);
|
const dto = await this.issueUserExpander.expand(issue);
|
||||||
|
|
||||||
if (issue.assignee) {
|
if (issue.assignee) {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Поля задачи, изменения которых пишутся в историю (таблица issue_change).
|
||||||
|
* created — служебное «поле»: строка о создании задачи, значения пустые.
|
||||||
|
*/
|
||||||
|
export const ISSUE_CHANGE_FIELDS = [
|
||||||
|
'created',
|
||||||
|
'title',
|
||||||
|
'content',
|
||||||
|
'status',
|
||||||
|
'priority',
|
||||||
|
'assignee',
|
||||||
|
'dueDate',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Изменение одного поля задачи. Значения сериализованы в строки:
|
||||||
|
* priority — число, dueDate — ISO-дата, assignee — id пользователя.
|
||||||
|
*/
|
||||||
|
export interface IssueFieldChange {
|
||||||
|
field: IssueChangeField;
|
||||||
|
oldValue: string | null;
|
||||||
|
newValue: string | null;
|
||||||
|
}
|
||||||
@@ -148,4 +148,76 @@ describe('Issue', () => {
|
|||||||
expect(issue.assignee).toBeNull();
|
expect(issue.assignee).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('change tracking', () => {
|
||||||
|
const build = () =>
|
||||||
|
Issue.reconstitute({
|
||||||
|
id: 1,
|
||||||
|
key: 'ISSUE-1',
|
||||||
|
title: 'Title',
|
||||||
|
content: null,
|
||||||
|
status: 'OPEN',
|
||||||
|
priority: 0,
|
||||||
|
assignee: null,
|
||||||
|
reporter: 'user-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
dueDate: null,
|
||||||
|
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records creation and the initial OPEN status for a new issue', () => {
|
||||||
|
const issue = Issue.create({ key: 'ISSUE-1', title: 'Title', reporter: 'user-1', projectId: 'project-1' });
|
||||||
|
|
||||||
|
expect(issue.pullChanges()).toEqual([
|
||||||
|
{ field: 'created', oldValue: null, newValue: null },
|
||||||
|
{ field: 'status', oldValue: null, newValue: 'OPEN' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records every changed field with serialized values', () => {
|
||||||
|
const issue = build();
|
||||||
|
|
||||||
|
issue.updateTitle('New title');
|
||||||
|
issue.updateContent('Text');
|
||||||
|
issue.changeStatus('IN_PROGRESS');
|
||||||
|
issue.changePriority(3);
|
||||||
|
issue.assignTo('user-2');
|
||||||
|
issue.updateDueDate(new Date('2026-02-01T00:00:00.000Z'));
|
||||||
|
|
||||||
|
expect(issue.pullChanges()).toEqual([
|
||||||
|
{ field: 'title', oldValue: 'Title', newValue: 'New title' },
|
||||||
|
{ field: 'content', oldValue: null, newValue: 'Text' },
|
||||||
|
{ field: 'status', oldValue: 'OPEN', newValue: 'IN_PROGRESS' },
|
||||||
|
{ field: 'priority', oldValue: '0', newValue: '3' },
|
||||||
|
{ field: 'assignee', oldValue: null, newValue: 'user-2' },
|
||||||
|
{ field: 'dueDate', oldValue: null, newValue: '2026-02-01T00:00:00.000Z' },
|
||||||
|
]);
|
||||||
|
expect(issue.pullChanges()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores setting the same value: no change, no event, no updatedAt bump', () => {
|
||||||
|
const issue = build();
|
||||||
|
const before = issue.updatedAt;
|
||||||
|
|
||||||
|
issue.updateTitle('Title');
|
||||||
|
issue.changeStatus('OPEN');
|
||||||
|
issue.unassign();
|
||||||
|
|
||||||
|
expect(issue.pullChanges()).toEqual([]);
|
||||||
|
expect(issue.pullDomainEvents()).toEqual([]);
|
||||||
|
expect(issue.updatedAt).toBe(before);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges repeated changes of a field and drops a change reverted to the original', () => {
|
||||||
|
const issue = build();
|
||||||
|
|
||||||
|
issue.changeStatus('IN_PROGRESS');
|
||||||
|
issue.changeStatus('DONE');
|
||||||
|
issue.assignTo('user-2');
|
||||||
|
issue.unassign();
|
||||||
|
|
||||||
|
expect(issue.pullChanges()).toEqual([{ field: 'status', oldValue: 'OPEN', newValue: 'DONE' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -3,6 +3,7 @@ import { IssueStatus } from '../value-objects/issue-status.vo.js';
|
|||||||
import { IssuePriority } from '../value-objects/issue-priority.vo.js';
|
import { IssuePriority } from '../value-objects/issue-priority.vo.js';
|
||||||
import { IssueCreatedEvent } from '../events/issue-created.event.js';
|
import { IssueCreatedEvent } from '../events/issue-created.event.js';
|
||||||
import { IssueUpdatedEvent } from '../events/issue-updated.event.js';
|
import { IssueUpdatedEvent } from '../events/issue-updated.event.js';
|
||||||
|
import type { IssueChangeField, IssueFieldChange } from './issue-change.js';
|
||||||
|
|
||||||
interface IssueProps {
|
interface IssueProps {
|
||||||
id: number | null;
|
id: number | null;
|
||||||
@@ -27,9 +28,14 @@ interface IssueProps {
|
|||||||
* `id` — Int, генерируется базой данных (SERIAL), поэтому у новой задачи
|
* `id` — Int, генерируется базой данных (SERIAL), поэтому у новой задачи
|
||||||
* он равен null до сохранения в репозитории. `key` (например, "ISSUE-1")
|
* он равен null до сохранения в репозитории. `key` (например, "ISSUE-1")
|
||||||
* присваивается на уровне приложения при создании и не зависит от id.
|
* присваивается на уровне приложения при создании и не зависит от id.
|
||||||
|
*
|
||||||
|
* Сущность копит изменения полей (pullChanges) — репозиторий сохраняет их
|
||||||
|
* в историю в той же транзакции, что и саму задачу.
|
||||||
*/
|
*/
|
||||||
export class Issue {
|
export class Issue {
|
||||||
private domainEvents: unknown[] = [];
|
private domainEvents: unknown[] = [];
|
||||||
|
/** Несохранённые изменения полей, по одному на поле, в порядке изменения */
|
||||||
|
private changes = new Map<IssueChangeField, IssueFieldChange>();
|
||||||
|
|
||||||
private constructor(private readonly props: IssueProps) {}
|
private constructor(private readonly props: IssueProps) {}
|
||||||
|
|
||||||
@@ -59,6 +65,8 @@ export class Issue {
|
|||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
issue.changes.set('created', { field: 'created', oldValue: null, newValue: null });
|
||||||
|
issue.changes.set('status', { field: 'status', oldValue: null, newValue: issue.status });
|
||||||
issue.addDomainEvent(new IssueCreatedEvent(issue.key));
|
issue.addDomainEvent(new IssueCreatedEvent(issue.key));
|
||||||
|
|
||||||
return issue;
|
return issue;
|
||||||
@@ -144,37 +152,67 @@ export class Issue {
|
|||||||
}
|
}
|
||||||
|
|
||||||
updateTitle(title: string): void {
|
updateTitle(title: string): void {
|
||||||
this.props.title = IssueTitle.create(title);
|
const next = IssueTitle.create(title);
|
||||||
this.touch();
|
this.recordChange('title', this.title, next.value);
|
||||||
|
this.props.title = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateContent(content: string | null): void {
|
updateContent(content: string | null): void {
|
||||||
|
this.recordChange('content', this.content, content);
|
||||||
this.props.content = content;
|
this.props.content = content;
|
||||||
this.touch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
changeStatus(status: string): void {
|
changeStatus(status: string): void {
|
||||||
this.props.status = IssueStatus.create(status);
|
const next = IssueStatus.create(status);
|
||||||
this.touch();
|
this.recordChange('status', this.status, next.value);
|
||||||
|
this.props.status = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
changePriority(priority: number): void {
|
changePriority(priority: number): void {
|
||||||
this.props.priority = IssuePriority.create(priority);
|
const next = IssuePriority.create(priority);
|
||||||
this.touch();
|
this.recordChange('priority', String(this.priority), String(next.value));
|
||||||
|
this.props.priority = next;
|
||||||
}
|
}
|
||||||
|
|
||||||
assignTo(userId: string): void {
|
assignTo(userId: string): void {
|
||||||
|
this.recordChange('assignee', this.assignee, userId);
|
||||||
this.props.assignee = userId;
|
this.props.assignee = userId;
|
||||||
this.touch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
unassign(): void {
|
unassign(): void {
|
||||||
|
this.recordChange('assignee', this.assignee, null);
|
||||||
this.props.assignee = null;
|
this.props.assignee = null;
|
||||||
this.touch();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateDueDate(dueDate: Date | null): void {
|
updateDueDate(dueDate: Date | null): void {
|
||||||
|
this.recordChange('dueDate', this.dueDate?.toISOString() ?? null, dueDate?.toISOString() ?? null);
|
||||||
this.props.dueDate = dueDate;
|
this.props.dueDate = dueDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Изменения полей, накопленные с момента загрузки/создания. Забираются один раз. */
|
||||||
|
pullChanges(): IssueFieldChange[] {
|
||||||
|
const changes = [...this.changes.values()];
|
||||||
|
this.changes.clear();
|
||||||
|
return changes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Запоминает изменение поля. Присвоение того же значения — не изменение:
|
||||||
|
* ни updatedAt, ни событие, ни запись в историю. Повторная правка поля
|
||||||
|
* сливается с предыдущей, а возврат к исходному значению её отменяет.
|
||||||
|
*/
|
||||||
|
private recordChange(field: IssueChangeField, oldValue: string | null, newValue: string | null): void {
|
||||||
|
if (oldValue === newValue) return;
|
||||||
|
|
||||||
|
const pending = this.changes.get(field);
|
||||||
|
if (!pending) {
|
||||||
|
this.changes.set(field, { field, oldValue, newValue });
|
||||||
|
} else if (pending.oldValue === newValue) {
|
||||||
|
this.changes.delete(field);
|
||||||
|
} else {
|
||||||
|
pending.newValue = newValue;
|
||||||
|
}
|
||||||
|
|
||||||
this.touch();
|
this.touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ export interface IIssueRepository {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
}): Promise<{ list: Issue[]; meta: PaginationMeta }>;
|
}): Promise<{ list: Issue[]; meta: PaginationMeta }>;
|
||||||
countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>;
|
countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>;
|
||||||
|
/** Сохраняет задачу и записи о её создании в историю (автор — reporter) */
|
||||||
create(issue: Issue): Promise<Issue>;
|
create(issue: Issue): Promise<Issue>;
|
||||||
update(issue: Issue): Promise<void>;
|
/** Сохраняет задачу и накопленные изменения полей в историю одной транзакцией */
|
||||||
|
update(issue: Issue, actorId: string): Promise<void>;
|
||||||
delete(id: number): Promise<void>;
|
delete(id: number): Promise<void>;
|
||||||
}
|
}
|
||||||
+40
-4
@@ -1,3 +1,4 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
|
||||||
import { Issue } from '../../../../domain/entities/issue.entity.js';
|
import { Issue } from '../../../../domain/entities/issue.entity.js';
|
||||||
@@ -8,6 +9,7 @@ import { PaginationMeta } from '../../../../../../common/interfaces/pagination-m
|
|||||||
import { splitCKey } from '../../../../domain/helpers/get-ckey.js';
|
import { splitCKey } from '../../../../domain/helpers/get-ckey.js';
|
||||||
import { Prisma } from '../../../../../../generated/prisma/client.js';
|
import { Prisma } from '../../../../../../generated/prisma/client.js';
|
||||||
import { IssueProjectNotFoundException } from '../../../../domain/exceptions/issue-project-not-found.exception.js';
|
import { IssueProjectNotFoundException } from '../../../../domain/exceptions/issue-project-not-found.exception.js';
|
||||||
|
import type { IssueFieldChange } from '../../../../domain/entities/issue-change.js';
|
||||||
|
|
||||||
const FOREIGN_KEY_VIOLATION = 'P2003';
|
const FOREIGN_KEY_VIOLATION = 'P2003';
|
||||||
|
|
||||||
@@ -16,6 +18,17 @@ function ownedByUser(userId: string) {
|
|||||||
return [{ assigneeId: userId }, { reporterId: userId }];
|
return [{ assigneeId: userId }, { reporterId: userId }];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Строки истории одного сохранения задачи — с общим changeId */
|
||||||
|
function toChangeRows(
|
||||||
|
issueId: number,
|
||||||
|
changes: IssueFieldChange[],
|
||||||
|
actorId: string | null,
|
||||||
|
changedAt: Date,
|
||||||
|
): Prisma.IssueChangeCreateManyInput[] {
|
||||||
|
const changeId = randomUUID();
|
||||||
|
return changes.map((change) => ({ issueId, changeId, actorId, changedAt, ...change }));
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class IssuePrismaRepository implements IIssueRepository {
|
export class IssuePrismaRepository implements IIssueRepository {
|
||||||
constructor(private readonly prisma: PrismaAdapter) {}
|
constructor(private readonly prisma: PrismaAdapter) {}
|
||||||
@@ -96,10 +109,18 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(issue: Issue): Promise<Issue> {
|
async create(issue: Issue): Promise<Issue> {
|
||||||
|
const changes = issue.pullChanges();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const record = await this.prisma.issue.create({
|
const record = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const created = await tx.issue.create({
|
||||||
data: IssuePrismaMapper.toCreateInput(issue),
|
data: IssuePrismaMapper.toCreateInput(issue),
|
||||||
});
|
});
|
||||||
|
await tx.issueChange.createMany({
|
||||||
|
data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt),
|
||||||
|
});
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
|
||||||
return IssuePrismaMapper.toDomain(record);
|
return IssuePrismaMapper.toDomain(record);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -110,11 +131,26 @@ export class IssuePrismaRepository implements IIssueRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(issue: Issue): Promise<void> {
|
async update(issue: Issue, actorId: string): Promise<void> {
|
||||||
await this.prisma.issue.update({
|
const issueId = issue.id as number;
|
||||||
where: { id: issue.id as number },
|
const changes = issue.pullChanges();
|
||||||
|
|
||||||
|
const updateIssue = this.prisma.issue.update({
|
||||||
|
where: { id: issueId },
|
||||||
data: IssuePrismaMapper.toUpdateInput(issue),
|
data: IssuePrismaMapper.toUpdateInput(issue),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (changes.length === 0) {
|
||||||
|
await updateIssue;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.prisma.$transaction([
|
||||||
|
updateIssue,
|
||||||
|
this.prisma.issueChange.createMany({
|
||||||
|
data: toChangeRows(issueId, changes, actorId, issue.updatedAt),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async delete(id: number): Promise<void> {
|
async delete(id: number): Promise<void> {
|
||||||
|
|||||||
Reference in new issue
Block a user