From cb29c7d2c4c7731616aae5b068193bcf5dd25312 Mon Sep 17 00:00:00 2001 From: Vadim Date: Wed, 7 Oct 2026 10:48:48 +0300 Subject: [PATCH] Add issue change history and daily issue stats MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../20261007120000_issue_change/migration.sql | 28 +++++ prisma/schema.prisma | 28 +++++ src/app.module.ts | 2 + .../application/dto/daily-issue-stats.dto.ts | 22 ++++ .../application/dto/issue-history.dto.ts | 21 ++++ .../get-daily-issue-stats.query.ts | 12 ++ .../get-daily-issue-stats.use-case.spec.ts | 77 +++++++++++++ .../get-daily-issue-stats.use-case.ts | 73 +++++++++++++ .../get-issue-history.query.ts | 6 + .../get-issue-history.use-case.spec.ts | 103 ++++++++++++++++++ .../get-issue-history.use-case.ts | 83 ++++++++++++++ .../invalid-stats-period.exception.ts | 6 + .../issue-history.repository.interface.ts | 46 ++++++++ .../value-objects/stats-period.vo.spec.ts | 42 +++++++ .../domain/value-objects/stats-period.vo.ts | 93 ++++++++++++++++ src/modules/history/history.module.ts | 30 +++++ .../issue-history.prisma-repository.ts | 89 +++++++++++++++ .../controllers/history.controller.ts | 50 +++++++++ .../dto/daily-issue-stats-query.dto.ts | 18 +++ .../update-issue.use-case.spec.ts | 8 ++ .../update-issue/update-issue.use-case.ts | 2 +- .../issue/domain/entities/issue-change.ts | 25 +++++ .../domain/entities/issue.entity.spec.ts | 72 ++++++++++++ .../issue/domain/entities/issue.entity.ts | 56 ++++++++-- .../issue.repository.interface.ts | 4 +- .../repositories/issue.prisma-repository.ts | 46 +++++++- 26 files changed, 1026 insertions(+), 16 deletions(-) create mode 100644 prisma/migrations/20261007120000_issue_change/migration.sql create mode 100644 src/modules/history/application/dto/daily-issue-stats.dto.ts create mode 100644 src/modules/history/application/dto/issue-history.dto.ts create mode 100644 src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.query.ts create mode 100644 src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.spec.ts create mode 100644 src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.ts create mode 100644 src/modules/history/application/use-cases/get-issue-history/get-issue-history.query.ts create mode 100644 src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts create mode 100644 src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.ts create mode 100644 src/modules/history/domain/exceptions/invalid-stats-period.exception.ts create mode 100644 src/modules/history/domain/repositories/issue-history.repository.interface.ts create mode 100644 src/modules/history/domain/value-objects/stats-period.vo.spec.ts create mode 100644 src/modules/history/domain/value-objects/stats-period.vo.ts create mode 100644 src/modules/history/history.module.ts create mode 100644 src/modules/history/infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.ts create mode 100644 src/modules/history/presentation/controllers/history.controller.ts create mode 100644 src/modules/history/presentation/dto/daily-issue-stats-query.dto.ts create mode 100644 src/modules/issue/domain/entities/issue-change.ts diff --git a/prisma/migrations/20261007120000_issue_change/migration.sql b/prisma/migrations/20261007120000_issue_change/migration.sql new file mode 100644 index 0000000..a27585d --- /dev/null +++ b/prisma/migrations/20261007120000_issue_change/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5747452..2f43a53 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,6 +26,7 @@ model User { notificationMessages NotificationMessage[] comments Comment[] uploadedFiles File[] + issueChanges IssueChange[] @@unique([email]) @@map("user") @@ -96,6 +97,7 @@ model Issue { dueDate DateTime? @db.Timestamptz(3) comments Comment[] attachments IssueAttachment[] + changes IssueChange[] createdAt DateTime @default(now()) @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3) @@ -264,3 +266,29 @@ model CommentAttachment { @@index([fileId]) @@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") +} diff --git a/src/app.module.ts b/src/app.module.ts index 501663c..96c05ac 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -9,6 +9,7 @@ import { NotificationModule } from './modules/notification/notification.module.j import { CommentModule } from './modules/comment/comment.module.js'; import { FileModule } from './modules/file/file.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 { auth } from './lib/auth.js'; import { ConfigModule } from '@nestjs/config'; @@ -55,6 +56,7 @@ import { join } from 'node:path'; CommentModule, FileModule, IssueAttachmentModule, + HistoryModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/modules/history/application/dto/daily-issue-stats.dto.ts b/src/modules/history/application/dto/daily-issue-stats.dto.ts new file mode 100644 index 0000000..5e64190 --- /dev/null +++ b/src/modules/history/application/dto/daily-issue-stats.dto.ts @@ -0,0 +1,22 @@ +import type { IssueStatusValue } from '../../../issue/domain/value-objects/issue-status.vo.js'; + +/** Счётчик по каждому статусу; статусы без задач — 0 */ +export type IssueStatusCounts = Record; + +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[]; +} diff --git a/src/modules/history/application/dto/issue-history.dto.ts b/src/modules/history/application/dto/issue-history.dto.ts new file mode 100644 index 0000000..55eab1e --- /dev/null +++ b/src/modules/history/application/dto/issue-history.dto.ts @@ -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[]; +} diff --git a/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.query.ts b/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.query.ts new file mode 100644 index 0000000..292e7e5 --- /dev/null +++ b/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.query.ts @@ -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, + ) {} +} diff --git a/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.spec.ts b/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.spec.ts new file mode 100644 index 0000000..a11a477 --- /dev/null +++ b/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.spec.ts @@ -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(); + }); +}); diff --git a/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.ts b/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.ts new file mode 100644 index 0000000..aae474e --- /dev/null +++ b/src/modules/history/application/use-cases/get-daily-issue-stats/get-daily-issue-stats.use-case.ts @@ -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 { + 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( + 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); +} diff --git a/src/modules/history/application/use-cases/get-issue-history/get-issue-history.query.ts b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.query.ts new file mode 100644 index 0000000..68ab4b9 --- /dev/null +++ b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.query.ts @@ -0,0 +1,6 @@ +export class GetIssueHistoryQuery { + constructor( + public readonly issueId: number, + public readonly userId: string, + ) {} +} diff --git a/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts new file mode 100644 index 0000000..4c2f5c9 --- /dev/null +++ b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts @@ -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 & Pick, +): 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(); + }); +}); diff --git a/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.ts b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.ts new file mode 100644 index 0000000..7c13900 --- /dev/null +++ b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.ts @@ -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 { + 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(); + 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> { + const ids = new Set(); + 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])); + } +} diff --git a/src/modules/history/domain/exceptions/invalid-stats-period.exception.ts b/src/modules/history/domain/exceptions/invalid-stats-period.exception.ts new file mode 100644 index 0000000..8f13921 --- /dev/null +++ b/src/modules/history/domain/exceptions/invalid-stats-period.exception.ts @@ -0,0 +1,6 @@ +export class InvalidStatsPeriodException extends Error { + constructor(message: string) { + super(message); + this.name = 'InvalidStatsPeriodException'; + } +} diff --git a/src/modules/history/domain/repositories/issue-history.repository.interface.ts b/src/modules/history/domain/repositories/issue-history.repository.interface.ts new file mode 100644 index 0000000..849f649 --- /dev/null +++ b/src/modules/history/domain/repositories/issue-history.repository.interface.ts @@ -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; + /** Только задачи, видимые пользователю (reporter/assignee) */ + countDailyEvents(range: DailyStatsRange): Promise; + /** Только задачи, видимые пользователю (reporter/assignee) */ + countDailyStatuses(range: DailyStatsRange): Promise; +} diff --git a/src/modules/history/domain/value-objects/stats-period.vo.spec.ts b/src/modules/history/domain/value-objects/stats-period.vo.spec.ts new file mode 100644 index 0000000..7ac1e1e --- /dev/null +++ b/src/modules/history/domain/value-objects/stats-period.vo.spec.ts @@ -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); + }); +}); diff --git a/src/modules/history/domain/value-objects/stats-period.vo.ts b/src/modules/history/domain/value-objects/stats-period.vo.ts new file mode 100644 index 0000000..d2a6021 --- /dev/null +++ b/src/modules/history/domain/value-objects/stats-period.vo.ts @@ -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')}`; +} diff --git a/src/modules/history/history.module.ts b/src/modules/history/history.module.ts new file mode 100644 index 0000000..86c4189 --- /dev/null +++ b/src/modules/history/history.module.ts @@ -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 {} diff --git a/src/modules/history/infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.ts b/src/modules/history/infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.ts new file mode 100644 index 0000000..00ef295 --- /dev/null +++ b/src/modules/history/infrastructure/persistence/prisma/repositories/issue-history.prisma-repository.ts @@ -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 { + 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 { + 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 { + return this.prisma.$queryRaw` + 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" + `; + } +} diff --git a/src/modules/history/presentation/controllers/history.controller.ts b/src/modules/history/presentation/controllers/history.controller.ts new file mode 100644 index 0000000..bdc296a --- /dev/null +++ b/src/modules/history/presentation/controllers/history.controller.ts @@ -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(fn: () => Promise): Promise { + 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; + } + } +} diff --git a/src/modules/history/presentation/dto/daily-issue-stats-query.dto.ts b/src/modules/history/presentation/dto/daily-issue-stats-query.dto.ts new file mode 100644 index 0000000..b2b63f0 --- /dev/null +++ b/src/modules/history/presentation/dto/daily-issue-stats-query.dto.ts @@ -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; +} diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts index cf82afb..86ff49a 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts @@ -72,6 +72,14 @@ describe('UpdateIssueUseCase', () => { 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 () => { vi.mocked(repository.findById).mockResolvedValue(buildIssue()); diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts index b11f85e..bce2cb7 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts @@ -57,7 +57,7 @@ export class UpdateIssueUseCase { issue.updateDueDate(command.dueDate); } - await this.issueRepository.update(issue); + await this.issueRepository.update(issue, command.userId); const dto = await this.issueUserExpander.expand(issue); if (issue.assignee) { diff --git a/src/modules/issue/domain/entities/issue-change.ts b/src/modules/issue/domain/entities/issue-change.ts new file mode 100644 index 0000000..19b0114 --- /dev/null +++ b/src/modules/issue/domain/entities/issue-change.ts @@ -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; +} diff --git a/src/modules/issue/domain/entities/issue.entity.spec.ts b/src/modules/issue/domain/entities/issue.entity.spec.ts index 64db33c..413f07b 100644 --- a/src/modules/issue/domain/entities/issue.entity.spec.ts +++ b/src/modules/issue/domain/entities/issue.entity.spec.ts @@ -148,4 +148,76 @@ describe('Issue', () => { 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' }]); + }); + }); }); diff --git a/src/modules/issue/domain/entities/issue.entity.ts b/src/modules/issue/domain/entities/issue.entity.ts index eb7539f..08eca47 100644 --- a/src/modules/issue/domain/entities/issue.entity.ts +++ b/src/modules/issue/domain/entities/issue.entity.ts @@ -3,6 +3,7 @@ import { IssueStatus } from '../value-objects/issue-status.vo.js'; import { IssuePriority } from '../value-objects/issue-priority.vo.js'; import { IssueCreatedEvent } from '../events/issue-created.event.js'; import { IssueUpdatedEvent } from '../events/issue-updated.event.js'; +import type { IssueChangeField, IssueFieldChange } from './issue-change.js'; interface IssueProps { id: number | null; @@ -27,9 +28,14 @@ interface IssueProps { * `id` — Int, генерируется базой данных (SERIAL), поэтому у новой задачи * он равен null до сохранения в репозитории. `key` (например, "ISSUE-1") * присваивается на уровне приложения при создании и не зависит от id. + * + * Сущность копит изменения полей (pullChanges) — репозиторий сохраняет их + * в историю в той же транзакции, что и саму задачу. */ export class Issue { private domainEvents: unknown[] = []; + /** Несохранённые изменения полей, по одному на поле, в порядке изменения */ + private changes = new Map(); private constructor(private readonly props: IssueProps) {} @@ -59,6 +65,8 @@ export class Issue { 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)); return issue; @@ -144,37 +152,67 @@ export class Issue { } updateTitle(title: string): void { - this.props.title = IssueTitle.create(title); - this.touch(); + const next = IssueTitle.create(title); + this.recordChange('title', this.title, next.value); + this.props.title = next; } updateContent(content: string | null): void { + this.recordChange('content', this.content, content); this.props.content = content; - this.touch(); } changeStatus(status: string): void { - this.props.status = IssueStatus.create(status); - this.touch(); + const next = IssueStatus.create(status); + this.recordChange('status', this.status, next.value); + this.props.status = next; } changePriority(priority: number): void { - this.props.priority = IssuePriority.create(priority); - this.touch(); + const next = IssuePriority.create(priority); + this.recordChange('priority', String(this.priority), String(next.value)); + this.props.priority = next; } assignTo(userId: string): void { + this.recordChange('assignee', this.assignee, userId); this.props.assignee = userId; - this.touch(); } unassign(): void { + this.recordChange('assignee', this.assignee, null); this.props.assignee = null; - this.touch(); } updateDueDate(dueDate: Date | null): void { + this.recordChange('dueDate', this.dueDate?.toISOString() ?? null, dueDate?.toISOString() ?? null); 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(); } diff --git a/src/modules/issue/domain/repositories/issue.repository.interface.ts b/src/modules/issue/domain/repositories/issue.repository.interface.ts index 0b06d89..ccb8ca8 100644 --- a/src/modules/issue/domain/repositories/issue.repository.interface.ts +++ b/src/modules/issue/domain/repositories/issue.repository.interface.ts @@ -18,7 +18,9 @@ export interface IIssueRepository { projectId?: string; }): Promise<{ list: Issue[]; meta: PaginationMeta }>; countByStatus(userId: string, projectId?: string): Promise>; + /** Сохраняет задачу и записи о её создании в историю (автор — reporter) */ create(issue: Issue): Promise; - update(issue: Issue): Promise; + /** Сохраняет задачу и накопленные изменения полей в историю одной транзакцией */ + update(issue: Issue, actorId: string): Promise; delete(id: number): Promise; } diff --git a/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts b/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts index 1738158..6272bfe 100644 --- a/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts +++ b/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto'; import { Injectable } from '@nestjs/common'; import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.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 { Prisma } from '../../../../../../generated/prisma/client.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'; @@ -16,6 +18,17 @@ function ownedByUser(userId: string) { 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() export class IssuePrismaRepository implements IIssueRepository { constructor(private readonly prisma: PrismaAdapter) {} @@ -96,9 +109,17 @@ export class IssuePrismaRepository implements IIssueRepository { } async create(issue: Issue): Promise { + const changes = issue.pullChanges(); + try { - const record = await this.prisma.issue.create({ - data: IssuePrismaMapper.toCreateInput(issue), + const record = await this.prisma.$transaction(async (tx) => { + const created = await tx.issue.create({ + data: IssuePrismaMapper.toCreateInput(issue), + }); + await tx.issueChange.createMany({ + data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt), + }); + return created; }); return IssuePrismaMapper.toDomain(record); @@ -110,11 +131,26 @@ export class IssuePrismaRepository implements IIssueRepository { } } - async update(issue: Issue): Promise { - await this.prisma.issue.update({ - where: { id: issue.id as number }, + async update(issue: Issue, actorId: string): Promise { + const issueId = issue.id as number; + const changes = issue.pullChanges(); + + const updateIssue = this.prisma.issue.update({ + where: { id: issueId }, 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 {