Author SHA1 Message Date
vadim cb29c7d2c4 Add issue change history and daily issue stats
Gitea Actions Demo / Deploy (push) Successful in 32s
Issue changes are recorded per field in the issue_change table, in the same
transaction as the issue itself. The new history module exposes:
- GET /api/issues/:issueId/history — change timeline grouped by save
- GET /api/issues/stats/daily — created, status transitions and status
  snapshot per day, with time zone support

The migration backfills history for existing issues. Setting a field to its
current value no longer bumps updatedAt or emits issue.updated.
2026-10-08 09:55:21 +03:00
vadim f44590b8ca update issue filter
Gitea Actions Demo / Deploy (push) Successful in 31s
2026-10-06 23:18:00 +03:00
29 changed files with 1099 additions and 22 deletions

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;
+28
View File
@@ -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")
}
+2
View File
@@ -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],
@@ -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[];
}
@@ -0,0 +1,12 @@
export class GetDailyIssueStatsQuery {
constructor(
public readonly userId: string,
public readonly projectId?: string,
/** YYYY-MM-DD */
public readonly from?: string,
/** YYYY-MM-DD */
public readonly to?: string,
/** IANA, например Europe/Moscow */
public readonly timeZone?: string,
) {}
}
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IIssueHistoryRepository } from '../../../domain/repositories/issue-history.repository.interface.js';
import { InvalidStatsPeriodException } from '../../../domain/exceptions/invalid-stats-period.exception.js';
import { GetDailyIssueStatsUseCase } from './get-daily-issue-stats.use-case.js';
import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
const zero = { OPEN: 0, IN_PROGRESS: 0, DONE: 0, CANCELLED: 0 };
describe('GetDailyIssueStatsUseCase', () => {
let repository: IIssueHistoryRepository;
let useCase: GetDailyIssueStatsUseCase;
beforeEach(() => {
repository = {
findByIssue: vi.fn(),
countDailyEvents: vi.fn().mockResolvedValue([]),
countDailyStatuses: vi.fn().mockResolvedValue([]),
};
useCase = new GetDailyIssueStatsUseCase(repository);
});
it('passes the validated period and visibility params to the repository', async () => {
await useCase.execute(
new GetDailyIssueStatsQuery('user-1', 'project-1', '2026-10-01', '2026-10-03', 'Europe/Moscow'),
);
const range = {
userId: 'user-1',
projectId: 'project-1',
from: '2026-10-01',
to: '2026-10-03',
timeZone: 'Europe/Moscow',
};
expect(repository.countDailyEvents).toHaveBeenCalledWith(range);
expect(repository.countDailyStatuses).toHaveBeenCalledWith(range);
});
it('fills every day and every status, including the empty ones', async () => {
vi.mocked(repository.countDailyEvents).mockResolvedValue([
{ date: '2026-10-01', kind: 'created', count: 2 },
{ date: '2026-10-03', kind: 'status', status: 'DONE', count: 1 },
]);
vi.mocked(repository.countDailyStatuses).mockResolvedValue([
{ date: '2026-10-01', status: 'OPEN', count: 2 },
{ date: '2026-10-03', status: 'OPEN', count: 1 },
{ date: '2026-10-03', status: 'DONE', count: 1 },
]);
const result = await useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-01', '2026-10-03'));
expect(result).toEqual({
from: '2026-10-01',
to: '2026-10-03',
timeZone: 'UTC',
days: [
{ date: '2026-10-01', created: 2, transitions: zero, byStatus: { ...zero, OPEN: 2 } },
{ date: '2026-10-02', created: 0, transitions: zero, byStatus: zero },
{ date: '2026-10-03', created: 0, transitions: { ...zero, DONE: 1 }, byStatus: { ...zero, OPEN: 1, DONE: 1 } },
],
});
});
it('ignores statuses that are no longer known', async () => {
vi.mocked(repository.countDailyStatuses).mockResolvedValue([{ date: '2026-10-01', status: 'LEGACY', count: 5 }]);
const result = await useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-01', '2026-10-01'));
expect(result.days[0]!.byStatus).toEqual(zero);
});
it('rejects an invalid period without querying the database', async () => {
await expect(
useCase.execute(new GetDailyIssueStatsQuery('user-1', undefined, '2026-10-05', '2026-10-01')),
).rejects.toThrow(InvalidStatsPeriodException);
expect(repository.countDailyEvents).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,73 @@
import { Inject, Injectable } from '@nestjs/common';
import { ISSUE_STATUSES, type IssueStatusValue } from '../../../../issue/domain/value-objects/issue-status.vo.js';
import {
ISSUE_HISTORY_REPOSITORY,
type IIssueHistoryRepository,
} from '../../../domain/repositories/issue-history.repository.interface.js';
import { StatsPeriod } from '../../../domain/value-objects/stats-period.vo.js';
import { DailyIssueStatsDayDto, DailyIssueStatsDto, IssueStatusCounts } from '../../dto/daily-issue-stats.dto.js';
import { GetDailyIssueStatsQuery } from './get-daily-issue-stats.query.js';
/**
* Статистика задач по дням, считается по истории изменений (issue_change).
* Видимость — как у списка задач: только задачи, где пользователь reporter/assignee.
*/
@Injectable()
export class GetDailyIssueStatsUseCase {
constructor(
@Inject(ISSUE_HISTORY_REPOSITORY)
private readonly historyRepository: IIssueHistoryRepository,
) {}
async execute(query: GetDailyIssueStatsQuery): Promise<DailyIssueStatsDto> {
const period = StatsPeriod.create({ from: query.from, to: query.to, timeZone: query.timeZone });
const range = {
userId: query.userId,
projectId: query.projectId,
from: period.from,
to: period.to,
timeZone: period.timeZone,
};
const [events, statuses] = await Promise.all([
this.historyRepository.countDailyEvents(range),
this.historyRepository.countDailyStatuses(range),
]);
// Каждый день периода попадает в ответ, даже если в нём ничего не было
const days = new Map<string, DailyIssueStatsDayDto>(
period.days.map((date) => [
date,
{ date, created: 0, transitions: emptyStatusCounts(), byStatus: emptyStatusCounts() },
]),
);
for (const event of events) {
const day = days.get(event.date);
if (!day) continue;
if (event.kind === 'created') {
day.created += event.count;
} else if (isKnownStatus(event.status)) {
day.transitions[event.status] += event.count;
}
}
for (const entry of statuses) {
const day = days.get(entry.date);
if (day && isKnownStatus(entry.status)) {
day.byStatus[entry.status] += entry.count;
}
}
return { from: period.from, to: period.to, timeZone: period.timeZone, days: [...days.values()] };
}
}
function emptyStatusCounts(): IssueStatusCounts {
return Object.fromEntries(ISSUE_STATUSES.map((status) => [status.alias, 0])) as IssueStatusCounts;
}
function isKnownStatus(status: string): status is IssueStatusValue {
return ISSUE_STATUSES.some((known) => known.alias === status);
}
@@ -0,0 +1,6 @@
export class GetIssueHistoryQuery {
constructor(
public readonly issueId: number,
public readonly userId: string,
) {}
}
@@ -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();
});
});
@@ -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')}`;
}
+30
View File
@@ -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 {}
@@ -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;
}
@@ -4,7 +4,7 @@ export class ListIssuesQuery {
public readonly page: number,
public readonly userId: string,
public readonly projectId?: string,
public readonly status?: string,
public readonly status?: string[],
public readonly text?: string,
public readonly assigneeId?: string,
) {}
@@ -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());
@@ -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) {
@@ -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();
});
});
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 { 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<IssueChangeField, IssueFieldChange>();
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();
}
@@ -12,13 +12,15 @@ export interface IIssueRepository {
limit: number;
page: number;
userId: string;
status?: string;
status?: string[];
text?: string;
assigneeId?: string;
projectId?: string;
}): Promise<{ list: Issue[]; meta: PaginationMeta }>;
countByStatus(userId: string, projectId?: string): Promise<Array<{ status: string; count: number }>>;
/** Сохраняет задачу и записи о её создании в историю (автор — reporter) */
create(issue: Issue): Promise<Issue>;
update(issue: Issue): Promise<void>;
/** Сохраняет задачу и накопленные изменения полей в историю одной транзакцией */
update(issue: Issue, actorId: string): Promise<void>;
delete(id: number): Promise<void>;
}
@@ -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) {}
@@ -40,7 +53,7 @@ export class IssuePrismaRepository implements IIssueRepository {
limit: number;
page: number;
userId: string;
status?: string;
status?: string[];
text?: string;
assigneeId?: string;
projectId?: string;
@@ -52,7 +65,7 @@ export class IssuePrismaRepository implements IIssueRepository {
where: {
AND: [
{ OR: ownedByUser(params.userId) },
...(params.status ? [{ status: params.status }] : []),
...(params.status?.length ? [{ status: { in: params.status } }] : []),
...(params.assigneeId ? [{ assigneeId: params.assigneeId }] : []),
...(params.projectId ? [{ projectId: params.projectId }] : []),
...(params.text
@@ -96,9 +109,17 @@ export class IssuePrismaRepository implements IIssueRepository {
}
async create(issue: Issue): Promise<Issue> {
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<void> {
await this.prisma.issue.update({
where: { id: issue.id as number },
async update(issue: Issue, actorId: string): Promise<void> {
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<void> {
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';
import { plainToInstance } from 'class-transformer';
import { validateSync } from 'class-validator';
import { FilterIssueDto } from './filter-issue.dto.js';
const parse = (query: Record<string, unknown>) => {
const dto = plainToInstance(FilterIssueDto, query);
return { dto, errors: validateSync(dto) };
};
describe('FilterIssueDto.status', () => {
it('leaves status undefined when absent', () => {
const { dto, errors } = parse({});
expect(dto.status).toBeUndefined();
expect(errors).toHaveLength(0);
});
it('wraps a single status into an array', () => {
const { dto, errors } = parse({ status: 'OPEN' });
expect(dto.status).toEqual(['OPEN']);
expect(errors).toHaveLength(0);
});
it('splits a comma-separated list', () => {
const { dto, errors } = parse({ status: 'OPEN, IN_PROGRESS' });
expect(dto.status).toEqual(['OPEN', 'IN_PROGRESS']);
expect(errors).toHaveLength(0);
});
it('accepts a repeated query param', () => {
const { dto, errors } = parse({ status: ['OPEN', 'DONE'] });
expect(dto.status).toEqual(['OPEN', 'DONE']);
expect(errors).toHaveLength(0);
});
it('rejects an unknown status', () => {
const { errors } = parse({ status: 'OPEN,UNKNOWN' });
expect(errors.map((el) => el.property)).toEqual(['status']);
});
it('rejects an empty value', () => {
const { errors } = parse({ status: '' });
expect(errors.map((el) => el.property)).toEqual(['status']);
});
});
@@ -1,4 +1,7 @@
import { Transform } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsIn, IsOptional } from 'class-validator';
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
export class FilterIssueDto {
@IntDecorator({ isOptional: true })
@@ -13,8 +16,21 @@ export class FilterIssueDto {
@StringDecorator({ isOptional: true })
assigneeId?: string;
@StringDecorator({ isOptional: true })
status?: string;
/** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */
@IsOptional()
@Transform(({ value }) =>
value === undefined || value === null
? undefined
: ([] as unknown[])
.concat(value)
.flatMap((el) => String(el).split(','))
.map((el) => el.trim())
.filter(Boolean),
)
@IsArray()
@ArrayNotEmpty()
@IsIn(ISSUE_STATUSES.map((status) => status.alias), { each: true })
status?: string[];
@StringDecorator({ isOptional: true })
projectId?: string;