tags
Gitea Actions / Deploy (push) Canceled after 45m38s

This commit is contained in:
vadim committed 2026-10-28 16:22:24 +03:00
1 parent b1f6b61994
commit d170fc8790
67 files changed
+1599 -30

No files matched your search

@@ -0,0 +1,37 @@
-- CreateTable
CREATE TABLE "tag" (
"id" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"normalizedName" TEXT NOT NULL,
"color" TEXT NOT NULL DEFAULT 'default',
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "tag_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "issue_tag" (
"issueId" INTEGER NOT NULL,
"tagId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "issue_tag_pkey" PRIMARY KEY ("issueId","tagId")
);
-- CreateIndex
CREATE UNIQUE INDEX "tag_projectId_normalizedName_key" ON "tag"("projectId", "normalizedName");
-- CreateIndex
CREATE INDEX "issue_tag_tagId_idx" ON "issue_tag"("tagId");
-- AddForeignKey
ALTER TABLE "tag" ADD CONSTRAINT "tag_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue_tag" ADD CONSTRAINT "issue_tag_issueId_fkey" FOREIGN KEY ("issueId") REFERENCES "issue"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue_tag" ADD CONSTRAINT "issue_tag_tagId_fkey" FOREIGN KEY ("tagId") REFERENCES "tag"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+37 -1
View File
@@ -105,6 +105,7 @@ model Issue {
attachments IssueAttachment[]
changes IssueChange[]
observers IssueObserver[]
tags IssueTag[]
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -145,6 +146,7 @@ model Project {
ownerId String
owner User @relation(fields: [ownerId], references: [id], onDelete: Restrict)
members ProjectMember[]
tags Tag[]
@@unique([key])
@@index([createdAt])
@@ -152,6 +154,38 @@ model Project {
@@map("project")
}
/// Тег — элемент справочника проекта. Уникален в проекте без учёта регистра:
/// normalizedName — name в нижнем регистре. color — из палитры (TAG_COLORS),
/// валидируется в коде. Задаче тег назначают по имени — отсутствующий создаётся.
model Tag {
id String @id @default(uuid(7))
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
name String
normalizedName String
color String @default("default")
issues IssueTag[]
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@unique([projectId, normalizedName])
@@map("tag")
}
/// Тег задачи. Удаление тега из справочника снимает его со всех задач (без записи в историю).
model IssueTag {
issueId Int
tagId String
createdAt DateTime @default(now()) @db.Timestamptz(3)
issue Issue @relation(fields: [issueId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([issueId, tagId])
@@index([tagId])
@@map("issue_tag")
}
/// MEMBER создаёт задачи в проекте; OBSERVER только видит проект —
/// он наблюдает за отдельными задачами (IssueObserver).
enum ProjectRole {
@@ -302,9 +336,11 @@ model CommentAttachment {
/// История изменений задачи — одна строка на изменённое поле.
/// Строки одного сохранения задачи объединены общим changeId.
/// field: created | title | content | status | priority | assignee | dueDate
/// field: created | title | content | status | priority | type | assignee | dueDate | observer | tag
/// (валидируется в коде, см. ISSUE_CHANGE_FIELDS). Значения — строки:
/// priority — число, dueDate — ISO-дата, assignee — id пользователя.
/// observer и tag — по строке на добавленный (oldValue = null) или снятый (newValue = null) элемент;
/// observer — id пользователя, tag — имя тега на момент изменения.
/// При создании задачи пишутся две строки: created и status null -> OPEN,
/// поэтому статус задачи на любой момент восстанавливается по строкам status.
/// У задач, созданных до появления истории, строк нет — в статистику они не попадают.
+2
View File
@@ -11,6 +11,7 @@ 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 { TagModule } from './modules/tag/tag.module.js';
import { AuthModule } from '@thallesp/nestjs-better-auth';
import { auth } from './lib/auth.js';
import { ConfigModule } from '@nestjs/config';
@@ -51,6 +52,7 @@ import { join } from 'node:path';
FileModule,
IssueAttachmentModule,
HistoryModule,
TagModule,
],
controllers: [AppController],
providers: [AppService],
@@ -0,0 +1,6 @@
/** Тег задачи — из справочника проекта */
export class IssueTagDto {
id: string;
name: string;
color: string;
}
@@ -1,3 +1,4 @@
import { IssueTagDto } from './issue-tag.dto.js';
import { UserDto } from '../../../user/application/dto/user.dto.js';
export class IssueWithUsersDto {
@@ -15,6 +16,7 @@ export class IssueWithUsersDto {
observerIds: string[];
/** Удалённые пользователи сюда не попадают */
observers: UserDto[];
tags: IssueTagDto[];
projectId: string;
dueDate: Date | null;
createdAt: Date;
@@ -1,3 +1,5 @@
import { IssueTagDto } from './issue-tag.dto.js';
export class IssueDto {
id: number;
key: string;
@@ -9,6 +11,7 @@ export class IssueDto {
assigneeId: string | null;
reporterId: string | null;
observerIds: string[];
tags: IssueTagDto[];
projectId: string;
dueDate: Date | null;
createdAt: Date;
@@ -2,8 +2,15 @@ import { Issue } from '../../domain/entities/issue.entity.js';
import { IssueDto } from '../dto/issue.dto.js';
import { IssueWithUsersDto } from '../dto/issue-with-users.dto.js';
import { UserDto } from '../../../user/application/dto/user.dto.js';
import { Tag } from '../../../tag/domain/entities/tag.entity.js';
import type { IssueTagRef } from '../../domain/value-objects/issue-tags.vo.js';
export class IssueApplicationMapper {
/** Тег справочника (уже сохранённый) → тег задачи */
static toTagRef(tag: Tag): IssueTagRef {
return { id: tag.id as string, name: tag.name, color: tag.color };
}
static toDto(issue: Issue): IssueDto {
return {
id: issue.id as number,
@@ -16,6 +23,7 @@ export class IssueApplicationMapper {
assigneeId: issue.assignee,
reporterId: issue.reporter,
observerIds: [...issue.observers],
tags: issue.tags.map((tag) => ({ ...tag })),
projectId: issue.projectId,
dueDate: issue.dueDate,
createdAt: issue.createdAt,
@@ -39,6 +47,7 @@ export class IssueApplicationMapper {
reporter: issue.reporter ? (usersById.get(issue.reporter) ?? null) : null,
observerIds: [...issue.observers],
observers: issue.observers.flatMap((id) => usersById.get(id) ?? []),
tags: issue.tags.map((tag) => ({ ...tag })),
projectId: issue.projectId,
dueDate: issue.dueDate,
createdAt: issue.createdAt,
@@ -10,5 +10,7 @@ export class CreateIssueCommand {
public readonly dueDate?: Date,
public readonly type?: string,
public readonly observers?: string[],
/** Имена тегов; отсутствующие в справочнике проекта создаются */
public readonly tags?: string[],
) {}
}
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
import type { ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
import { buildTag, mockTagRepository } from '../../../../tag/application/use-cases/test-helpers.js';
import type { IProjectRepository } from '../../../../project/domain/repositories/project.repository.interface.js';
import { Issue } from '../../../domain/entities/issue.entity.js';
import { CreateIssueUseCase } from './create-issue.use-case.js';
@@ -22,6 +24,7 @@ describe('CreateIssueUseCase', () => {
let issueUserExpander: IssueUserExpanderService;
let notificationsService: NotificationsService;
let getUsersUseCase: GetUsersUseCase;
let tagRepository: ITagRepository;
let useCase: CreateIssueUseCase;
beforeEach(() => {
@@ -56,9 +59,11 @@ describe('CreateIssueUseCase', () => {
getUsersUseCase = {
execute: vi.fn(async (query: GetUsersQuery) => query.ids.filter((id) => id !== 'ghost').map((id) => ({ id }))),
} as unknown as GetUsersUseCase;
tagRepository = mockTagRepository([buildTag({ id: 'tag-1', name: 'backend', color: 'primary' })]);
useCase = new CreateIssueUseCase(
repository,
projectRepository,
tagRepository,
issueEventsService,
issueUserExpander,
new IssueAssignmentNotifierService(notificationsService),
@@ -198,6 +203,57 @@ describe('CreateIssueUseCase', () => {
expect(bug.type).toBe('BUG');
});
it('assigns tags by name, creating the missing ones in the project', async () => {
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
const dto = await useCase.execute(
new CreateIssueCommand(
'ISSUE-6',
'Something',
'user-1',
'project-1',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
['ui', 'Backend', 'UI'],
),
);
expect(tagRepository.findOrCreateByNames).toHaveBeenCalledWith('project-1', ['ui', 'Backend', 'UI']);
// Существующий тег берётся в написании справочника
expect(dto.tags).toEqual([
{ id: 'tag-1', name: 'backend', color: 'primary' },
{ id: 'tag-ui', name: 'ui', color: 'default' },
]);
});
it('does not create tags in a project the reporter cannot create issues in', async () => {
vi.mocked(projectRepository.findRole).mockResolvedValue('OBSERVER');
await expect(
useCase.execute(
new CreateIssueCommand(
'ISSUE-7',
'Something',
'user-1',
'project-1',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
['ui'],
),
),
).rejects.toThrow();
expect(tagRepository.findOrCreateByNames).not.toHaveBeenCalled();
});
it('checks project access on behalf of the reporter', async () => {
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
@@ -8,12 +8,14 @@ import {
import { IssueProjectNotFoundException } from '../../../domain/exceptions/issue-project-not-found.exception.js';
import { IssueProjectForbiddenException } from '../../../domain/exceptions/issue-project-forbidden.exception.js';
import { CreateIssueCommand } from './create-issue.command.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import { IssueParticipantsService } from '../../services/issue-participants.service.js';
import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
import { TAG_REPOSITORY, type ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
@Injectable()
export class CreateIssueUseCase {
@@ -22,6 +24,8 @@ export class CreateIssueUseCase {
private readonly issueRepository: IIssueRepository,
@Inject(PROJECT_REPOSITORY)
private readonly projectRepository: IProjectRepository,
@Inject(TAG_REPOSITORY)
private readonly tagRepository: ITagRepository,
private readonly issueEventsService: IssueEventsService,
private readonly issueUserExpander: IssueUserExpanderService,
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
@@ -29,19 +33,6 @@ export class CreateIssueUseCase {
) {}
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
const draft = Issue.create({
key: command.key,
title: command.title,
content: command.content,
priority: command.priority,
type: command.type,
assignee: command.assignee,
reporter: command.reporter,
observers: command.observers,
projectId: command.projectId,
dueDate: command.dueDate,
});
// Создавать задачи может владелец или участник проекта, но не наблюдатель.
// Чужой проект неотличим от несуществующего — 404, как и раньше.
const role = await this.projectRepository.findRole(command.projectId, command.reporter);
@@ -52,6 +43,25 @@ export class CreateIssueUseCase {
throw new IssueProjectForbiddenException(command.projectId);
}
// Теги создаются в справочнике только после проверки доступа к проекту
const tags = command.tags?.length
? await this.tagRepository.findOrCreateByNames(command.projectId, command.tags)
: [];
const draft = Issue.create({
key: command.key,
title: command.title,
content: command.content,
priority: command.priority,
type: command.type,
assignee: command.assignee,
reporter: command.reporter,
observers: command.observers,
tags: tags.map(IssueApplicationMapper.toTagRef),
projectId: command.projectId,
dueDate: command.dueDate,
});
await this.issueParticipants.ensureObserversExist([...draft.observers]);
const issue = await this.issueRepository.create(draft);
const dto = await this.issueUserExpander.expand(issue);
@@ -11,5 +11,7 @@ export class UpdateIssueCommand {
public readonly type?: string,
/** Полный новый список наблюдателей */
public readonly observers?: string[],
/** Имена тегов (полный новый список); отсутствующие в справочнике проекта создаются */
public readonly tags?: string[],
) {}
}
@@ -1,5 +1,7 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
import type { ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
import { buildTag, mockTagRepository } from '../../../../tag/application/use-cases/test-helpers.js';
import type { IProjectRepository } from '../../../../project/domain/repositories/project.repository.interface.js';
import { Issue } from '../../../domain/entities/issue.entity.js';
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
@@ -16,7 +18,10 @@ import { IssueParticipantsService } from '../../services/issue-participants.serv
import type { GetUsersUseCase } from '../../../../user/application/use-cases/get-users/get-users.use-case.js';
import type { GetUsersQuery } from '../../../../user/application/use-cases/get-users/get-users.query.js';
const buildIssue = (overrides: { assignee?: string | null; observers?: string[] } = {}) =>
const backend = { id: 'tag-1', name: 'backend', color: 'primary' };
const ui = { id: 'tag-2', name: 'ui', color: 'default' };
const buildIssue = (overrides: { assignee?: string | null; observers?: string[]; tags?: Array<typeof backend> } = {}) =>
Issue.reconstitute({
id: 1,
key: 'ISSUE-1',
@@ -41,6 +46,7 @@ describe('UpdateIssueUseCase', () => {
let issueUserExpander: IssueUserExpanderService;
let notificationsService: NotificationsService;
let getUsersUseCase: GetUsersUseCase;
let tagRepository: ITagRepository;
let useCase: UpdateIssueUseCase;
beforeEach(() => {
@@ -75,8 +81,13 @@ describe('UpdateIssueUseCase', () => {
getUsersUseCase = {
execute: vi.fn(async (query: GetUsersQuery) => query.ids.filter((id) => id !== 'ghost').map((id) => ({ id }))),
} as unknown as GetUsersUseCase;
tagRepository = mockTagRepository([
buildTag({ id: backend.id, name: backend.name, color: backend.color }),
buildTag({ id: ui.id, name: ui.name, color: ui.color }),
]);
useCase = new UpdateIssueUseCase(
repository,
tagRepository,
issueEventsService,
issueUserExpander,
new IssueAssignmentNotifierService(notificationsService),
@@ -178,6 +189,46 @@ describe('UpdateIssueUseCase', () => {
expect(dto.type).toBe('BUG');
});
it('replaces the tags by name and saves the change to history', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ tags: [backend, ui] }));
let changes: unknown[] = [];
vi.mocked(repository.update).mockImplementation(async (issue) => {
changes = issue.pullChanges();
});
const dto = await useCase.execute(
new UpdateIssueCommand(
1,
'user-1',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
['UI', 'api'],
),
);
expect(tagRepository.findOrCreateByNames).toHaveBeenCalledWith('project-1', ['UI', 'api']);
expect(dto.tags.map((tag) => tag.name)).toEqual(['api', 'ui']);
expect(changes).toEqual([
{ field: 'tag', oldValue: 'backend', newValue: null },
{ field: 'tag', oldValue: null, newValue: 'api' },
]);
});
it('keeps the tags when they are not passed', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ tags: [backend] }));
const dto = await useCase.execute(new UpdateIssueCommand(1, 'user-1', 'New title'));
expect(dto.tags).toEqual([backend]);
expect(tagRepository.findOrCreateByNames).not.toHaveBeenCalled();
});
describe('observers', () => {
it('forbids an observer from editing the issue', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue({ observers: ['user-5'] }));
@@ -3,18 +3,22 @@ import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/reposit
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
import { IssueEditForbiddenException } from '../../../domain/exceptions/issue-edit-forbidden.exception.js';
import { UpdateIssueCommand } from './update-issue.command.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
import { IssueAssignmentNotifierService } from '../../services/issue-assignment-notifier.service.js';
import { IssueParticipantsService } from '../../services/issue-participants.service.js';
import { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
import { TAG_REPOSITORY, type ITagRepository } from '../../../../tag/domain/repositories/tag.repository.interface.js';
@Injectable()
export class UpdateIssueUseCase {
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
@Inject(TAG_REPOSITORY)
private readonly tagRepository: ITagRepository,
private readonly issueEventsService: IssueEventsService,
private readonly issueUserExpander: IssueUserExpanderService,
private readonly issueAssignmentNotifier: IssueAssignmentNotifierService,
@@ -71,6 +75,14 @@ export class UpdateIssueUseCase {
issue.setObservers(command.observers);
}
if (command.tags !== undefined) {
issue.setTags(
(await this.tagRepository.findOrCreateByNames(issue.projectId, command.tags)).map(
IssueApplicationMapper.toTagRef,
),
);
}
await this.issueParticipants.ensureObserversExist(issue.observers.filter((id) => !observersBefore.includes(id)));
await this.issueRepository.update(issue, command.userId);
const dto = await this.issueUserExpander.expand(issue);
@@ -12,6 +12,7 @@ export const ISSUE_CHANGE_FIELDS = [
'assignee',
'dueDate',
'observer',
'tag',
] as const;
export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
@@ -20,7 +21,7 @@ export type IssueChangeField = (typeof ISSUE_CHANGE_FIELDS)[number];
* Изменение одного поля задачи. Значения сериализованы в строки:
* priority — число, dueDate — ISO-дата, assignee — id пользователя.
* observer — одна строка на наблюдателя: добавлен (oldValue = null, newValue = id)
* или снят (oldValue = id, newValue = null).
* или снят (oldValue = id, newValue = null). tag — так же, по строке на тег.
*/
export interface IssueFieldChange {
field: IssueChangeField;
@@ -337,4 +337,87 @@ describe('Issue', () => {
]);
});
});
describe('tags', () => {
const backend = { id: 't1', name: 'backend', color: 'primary' };
const ui = { id: 't2', name: 'UI', color: 'default' };
const api = { id: 't3', name: 'api', color: 'success' };
const load = (tags: Array<typeof backend> = []) =>
Issue.reconstitute({
id: 1,
key: 'ISSUE-1',
title: 'Title',
content: null,
status: 'OPEN',
priority: 0,
type: 'TASK',
assignee: 'user-2',
reporter: 'user-1',
tags,
projectId: 'project-1',
dueDate: null,
createdAt: new Date(),
updatedAt: new Date(),
});
it('creates an issue with tags sorted by name without writing them to history', () => {
const issue = Issue.create({
key: 'ISSUE-1',
title: 'Title',
reporter: 'user-1',
tags: [ui, backend, ui],
projectId: 'project-1',
});
expect(issue.tags).toEqual([backend, ui]);
expect(issue.pullChanges().map((change) => change.field)).toEqual(['created', 'status']);
});
it('has no tags by default', () => {
expect(load().tags).toEqual([]);
});
it('records one history row per added or removed tag, with the tag name', () => {
const issue = load([backend, ui]);
issue.setTags([ui, api]);
expect(issue.tags).toEqual([api, ui]);
expect(issue.pullChanges()).toEqual([
{ field: 'tag', oldValue: 'backend', newValue: null },
{ field: 'tag', oldValue: null, newValue: 'api' },
]);
expect(issue.pullDomainEvents()).not.toHaveLength(0);
});
it('does not record a change when the same tags are set', () => {
const issue = load([backend, ui]);
const updatedAt = issue.updatedAt;
issue.setTags([ui, backend]);
expect(issue.pullChanges()).toEqual([]);
expect(issue.pullDomainEvents()).toEqual([]);
expect(issue.updatedAt).toBe(updatedAt);
});
it('cancels out adding and removing the same tag', () => {
const issue = load([backend]);
issue.setTags([backend, ui]);
issue.setTags([backend]);
expect(issue.pullChanges()).toEqual([]);
});
it('removes all tags', () => {
const issue = load([backend]);
issue.setTags([]);
expect(issue.tags).toEqual([]);
expect(issue.pullChanges()).toEqual([{ field: 'tag', oldValue: 'backend', newValue: null }]);
});
});
});
@@ -2,6 +2,7 @@ import { IssueTitle } from '../value-objects/issue-title.vo.js';
import { IssueStatus } from '../value-objects/issue-status.vo.js';
import { IssuePriority } from '../value-objects/issue-priority.vo.js';
import { IssueType } from '../value-objects/issue-type.vo.js';
import { IssueTags, type IssueTagRef } from '../value-objects/issue-tags.vo.js';
import { IssueCreatedEvent } from '../events/issue-created.event.js';
import { IssueUpdatedEvent } from '../events/issue-updated.event.js';
import { IssueAssignedEvent } from '../events/issue-assigned.event.js';
@@ -21,6 +22,7 @@ interface IssueProps {
reporter: string | null;
/** Видят задачу и комментируют, но не редактируют. Не пересекаются с reporter/assignee. */
observers: string[];
tags: IssueTags;
projectId: string;
dueDate: Date | null;
createdAt: Date;
@@ -42,7 +44,7 @@ export class Issue {
private domainEvents: unknown[] = [];
/**
* Несохранённые изменения полей, по одному на поле, в порядке изменения.
* Ключ — поле, для observer — поле и id наблюдателя.
* Ключ — поле, для observer — поле и id наблюдателя, для tag — поле и id тега.
*/
private changes = new Map<string, IssueFieldChange>();
@@ -58,6 +60,7 @@ export class Issue {
assignee?: string | null;
reporter: string;
observers?: string[];
tags?: IssueTagRef[];
projectId: string;
dueDate?: Date | null;
}): Issue {
@@ -72,6 +75,8 @@ export class Issue {
assignee: params.assignee ?? null,
reporter: params.reporter,
observers: [],
// Как и наблюдатели, теги при создании в историю не пишутся
tags: IssueTags.create(params.tags ?? []),
projectId: params.projectId,
dueDate: params.dueDate ?? null,
createdAt: new Date(),
@@ -105,6 +110,7 @@ export class Issue {
assignee: string | null;
reporter: string | null;
observers?: string[];
tags?: IssueTagRef[];
projectId: string;
dueDate: Date | null;
createdAt: Date;
@@ -121,6 +127,7 @@ export class Issue {
assignee: props.assignee,
reporter: props.reporter,
observers: [...(props.observers ?? [])],
tags: IssueTags.create(props.tags ?? []),
projectId: props.projectId,
dueDate: props.dueDate,
createdAt: props.createdAt,
@@ -168,6 +175,10 @@ export class Issue {
return this.props.observers;
}
get tags(): readonly IssueTagRef[] {
return this.props.tags.value;
}
/** Редактировать задачу могут только reporter и assignee, наблюдатели — нет */
canBeEditedBy(userId: string): boolean {
return this.reporter === userId || this.assignee === userId;
@@ -267,6 +278,21 @@ export class Issue {
this.props.observers = this.props.observers.filter((id) => id !== userId);
}
/**
* Заменяет теги задачи — теги справочника её проекта (их находит/создаёт use-case).
* В историю — по строке на добавленный или снятый тег, со значением-именем тега.
*/
setTags(tags: IssueTagRef[]): void {
const next = IssueTags.create(tags);
for (const tag of this.tags.filter((tag) => !next.has(tag.id))) {
this.recordChange('tag', tag.name, null, `tag:${tag.id}`);
}
for (const tag of next.value.filter((tag) => !this.props.tags.has(tag.id))) {
this.recordChange('tag', null, tag.name, `tag:${tag.id}`);
}
this.props.tags = next;
}
/** Изменения полей, накопленные с момента загрузки/создания. Забираются один раз. */
pullChanges(): IssueFieldChange[] {
const changes = [...this.changes.values()];
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { IssueTags, ISSUE_TAGS_MAX_COUNT } from './issue-tags.vo.js';
const tag = (id: string, name = id) => ({ id, name, color: 'default' });
describe('IssueTags', () => {
it('sorts tags by name and drops duplicates by id', () => {
const tags = IssueTags.create([tag('t2', 'ui'), tag('t1', 'backend'), tag('t2', 'ui')]);
expect(tags.value.map((t) => t.name)).toEqual(['backend', 'ui']);
});
it('throws for too many tags', () => {
const tags = Array.from({ length: ISSUE_TAGS_MAX_COUNT + 1 }, (_, i) => tag(`t${i}`));
expect(() => IssueTags.create(tags)).toThrow(/не может быть больше/);
});
it('checks whether a tag is assigned', () => {
const tags = IssueTags.create([tag('t1')]);
expect(tags.has('t1')).toBe(true);
expect(tags.has('t2')).toBe(false);
});
it('is empty by default', () => {
expect(IssueTags.empty().value).toEqual([]);
});
});
@@ -0,0 +1,35 @@
export const ISSUE_TAGS_MAX_COUNT = 20;
/** Тег из справочника проекта, назначенный задаче (снимок для отображения) */
export interface IssueTagRef {
id: string;
name: string;
color: string;
}
/** Теги задачи: без повторов (по id), по алфавиту, не больше ISSUE_TAGS_MAX_COUNT */
export class IssueTags {
private constructor(private readonly _value: readonly IssueTagRef[]) {}
static create(tags: IssueTagRef[]): IssueTags {
const unique = [...new Map(tags.map((tag) => [tag.id, { ...tag }])).values()];
if (unique.length > ISSUE_TAGS_MAX_COUNT) {
throw new Error(`У задачи не может быть больше ${ISSUE_TAGS_MAX_COUNT} тегов`);
}
return new IssueTags(unique.sort((a, b) => a.name.localeCompare(b.name)));
}
static empty(): IssueTags {
return new IssueTags([]);
}
get value(): readonly IssueTagRef[] {
return this._value;
}
has(tagId: string): boolean {
return this._value.some((tag) => tag.id === tagId);
}
}
@@ -11,6 +11,7 @@ describe('IssuePrismaMapper', () => {
status: 'IN_PROGRESS',
priority: 2,
type: 'BUG',
tags: [{ tag: { id: 't1', name: 'backend', color: 'primary' } }],
assigneeId: 'user-2',
reporterId: 'user-1',
projectId: 'project-1',
@@ -31,6 +32,7 @@ describe('IssuePrismaMapper', () => {
expect(issue.type).toBe('BUG');
expect(issue.assignee).toBe('user-2');
expect(issue.observers).toEqual(['user-3']);
expect(issue.tags).toEqual([{ id: 't1', name: 'backend', color: 'primary' }]);
});
it('maps a new domain entity to Prisma create input', () => {
@@ -38,6 +40,7 @@ describe('IssuePrismaMapper', () => {
key: 'ISSUE-6',
title: 'New issue',
reporter: 'user-1',
tags: [{ id: 't2', name: 'ui', color: 'default' }],
projectId: 'project-1',
});
@@ -52,6 +55,7 @@ describe('IssuePrismaMapper', () => {
type: 'TASK',
assigneeId: null,
reporterId: 'user-1',
tags: { create: [{ tagId: 't2' }] },
projectId: 'project-1',
});
});
@@ -72,5 +76,7 @@ describe('IssuePrismaMapper', () => {
expect(input).not.toHaveProperty('id');
expect(input).not.toHaveProperty('key');
expect(input).not.toHaveProperty('createdAt');
// Теги сохраняются отдельно — в issue_tag, вместе с историей
expect(input).not.toHaveProperty('tags');
});
});
@@ -2,11 +2,14 @@ import type { Issue as PrismaIssue, Prisma } from '../../../../../../generated/p
import { Issue } from '../../../../domain/entities/issue.entity.js';
import { getCKey } from '../../../../domain/helpers/get-ckey.js';
/** Запись задачи вместе с id наблюдателей (см. WITH_OBSERVERS в репозитории) */
export type PrismaIssueWithObservers = PrismaIssue & { observers: Array<{ userId: string }> };
/** Запись задачи вместе с id наблюдателей и тегами (см. WITH_RELATIONS в репозитории) */
export type PrismaIssueWithRelations = PrismaIssue & {
observers: Array<{ userId: string }>;
tags: Array<{ tag: { id: string; name: string; color: string } }>;
};
export class IssuePrismaMapper {
static toDomain(record: PrismaIssueWithObservers): Issue {
static toDomain(record: PrismaIssueWithRelations): Issue {
return Issue.reconstitute({
id: record.id,
key: getCKey(record.id, record.key),
@@ -18,6 +21,7 @@ export class IssuePrismaMapper {
assignee: record.assigneeId,
reporter: record.reporterId,
observers: record.observers.map((observer) => observer.userId),
tags: record.tags.map(({ tag }) => tag),
projectId: record.projectId,
dueDate: record.dueDate,
createdAt: record.createdAt,
@@ -36,6 +40,7 @@ export class IssuePrismaMapper {
assigneeId: issue.assignee,
reporterId: issue.reporter,
observers: { create: issue.observers.map((userId) => ({ userId })) },
tags: { create: issue.tags.map((tag) => ({ tagId: tag.id })) },
projectId: issue.projectId,
dueDate: issue.dueDate,
createdAt: issue.createdAt,
@@ -13,8 +13,11 @@ import type { IssueFieldChange } from '../../../../domain/entities/issue-change.
const FOREIGN_KEY_VIOLATION = 'P2003';
/** id наблюдателей подгружаются вместе с задачей — они часть сущности */
const WITH_OBSERVERS = { observers: { select: { userId: true } } } satisfies Prisma.IssueInclude;
/** id наблюдателей и теги подгружаются вместе с задачей — они часть сущности */
const WITH_RELATIONS = {
observers: { select: { userId: true } },
tags: { select: { tag: { select: { id: true, name: true, color: true } } } },
} satisfies Prisma.IssueInclude;
/**
* Задача видна своим reporter, assignee и наблюдателям.
@@ -70,7 +73,7 @@ export class IssuePrismaRepository implements IIssueRepository {
async findById(id: number, userId: string): Promise<Issue | null> {
const record = await this.prisma.issue.findFirst({
where: { id, OR: visibleTo(userId) },
include: WITH_OBSERVERS,
include: WITH_RELATIONS,
});
return record ? IssuePrismaMapper.toDomain(record) : null;
}
@@ -79,7 +82,7 @@ export class IssuePrismaRepository implements IIssueRepository {
const { id, key } = splitCKey(ckey);
const record = await this.prisma.issue.findFirst({
where: { id, key, OR: visibleTo(userId) },
include: WITH_OBSERVERS,
include: WITH_RELATIONS,
});
return record ? IssuePrismaMapper.toDomain(record) : null;
@@ -123,7 +126,7 @@ export class IssuePrismaRepository implements IIssueRepository {
const [records, totalCount] = await this.prisma.$transaction([
this.prisma.issue.findMany({
...queryArgs,
include: WITH_OBSERVERS,
include: WITH_RELATIONS,
take: limit,
skip: (page - 1) * limit,
orderBy: { createdAt: 'desc' },
@@ -150,7 +153,7 @@ export class IssuePrismaRepository implements IIssueRepository {
where: {
AND: [...commonFilters(params), { dueDate: { gte: params.from, lt: params.to } }],
},
include: WITH_OBSERVERS,
include: WITH_RELATIONS,
take: Math.max(1, params.limit),
orderBy: [{ dueDate: 'asc' }, { id: 'asc' }],
});
@@ -175,7 +178,7 @@ export class IssuePrismaRepository implements IIssueRepository {
const record = await this.prisma.$transaction(async (tx) => {
const created = await tx.issue.create({
data: IssuePrismaMapper.toCreateInput(issue),
include: WITH_OBSERVERS,
include: WITH_RELATIONS,
});
await tx.issueChange.createMany({
data: toChangeRows(created.id, changes, issue.reporter, issue.createdAt),
@@ -207,9 +210,10 @@ export class IssuePrismaRepository implements IIssueRepository {
}
const observers = [...issue.observers];
const tagIds = issue.tags.map((tag) => tag.id);
await this.prisma.$transaction([
updateIssue,
// Наблюдатели меняются только вместе с записью observer в истории
// Наблюдатели и теги меняются только вместе с записью observer/tag в истории
...(changes.some((change) => change.field === 'observer')
? [
this.prisma.issueObserver.deleteMany({ where: { issueId, userId: { notIn: observers } } }),
@@ -219,6 +223,15 @@ export class IssuePrismaRepository implements IIssueRepository {
}),
]
: []),
...(changes.some((change) => change.field === 'tag')
? [
this.prisma.issueTag.deleteMany({ where: { issueId, tagId: { notIn: tagIds } } }),
this.prisma.issueTag.createMany({
data: tagIds.map((tagId) => ({ issueId, tagId })),
skipDuplicates: true,
}),
]
: []),
this.prisma.issueChange.createMany({
data: toChangeRows(issueId, changes, actorId, issue.updatedAt),
}),
+2 -1
View File
@@ -3,6 +3,7 @@ import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { ProjectModule } from '../project/project.module.js';
import { UserModule } from '../user/user.module.js';
import { NotificationModule } from '../notification/notification.module.js';
import { TagModule } from '../tag/tag.module.js';
import { IssuePrismaRepository } from './infrastructure/persistence/prisma/repositories/issue.prisma-repository.js';
import { ISSUE_REPOSITORY } from './domain/repositories/issue.repository.interface.js';
import { IssueController } from './presentation/controllers/issue.controller.js';
@@ -22,7 +23,7 @@ import { UnobserveIssueUseCase } from './application/use-cases/unobserve-issue/u
import { IssueParticipantsService } from './application/services/issue-participants.service.js';
@Module({
imports: [PrismaModule, ProjectModule, UserModule, NotificationModule],
imports: [PrismaModule, ProjectModule, UserModule, NotificationModule, TagModule],
controllers: [IssueController],
providers: [
CreateIssueUseCase,
@@ -79,6 +79,7 @@ export class IssueController {
dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined,
dto.type,
dto.observers,
dto.tags,
),
),
);
@@ -182,6 +183,7 @@ export class IssueController {
dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate),
dto.type,
dto.observers,
dto.tags,
),
),
);
@@ -7,11 +7,14 @@ import {
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
import { ISSUE_TAGS_MAX_COUNT } from '../../domain/value-objects/issue-tags.vo.js';
import { TAG_NAME_MAX_LENGTH } from '../../../tag/domain/value-objects/tag-name.vo.js';
export class CreateIssueRequestDto {
@IsString()
@@ -55,4 +58,13 @@ export class CreateIssueRequestDto {
@ArrayMaxSize(50)
@IsString({ each: true })
observers?: string[];
/** Имена тегов; отсутствующие в справочнике проекта создаются, регистр не различается */
@IsOptional()
@IsArray()
@ArrayMaxSize(ISSUE_TAGS_MAX_COUNT)
@IsString({ each: true })
@Matches(/\S/, { each: true, message: 'tags не может содержать пустых имён' })
@MaxLength(TAG_NAME_MAX_LENGTH, { each: true })
tags?: string[];
}
@@ -6,12 +6,15 @@ import {
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
import { ISSUE_TAGS_MAX_COUNT } from '../../domain/value-objects/issue-tags.vo.js';
import { TAG_NAME_MAX_LENGTH } from '../../../tag/domain/value-objects/tag-name.vo.js';
export class UpdateIssueRequestDto {
@IsOptional()
@@ -51,4 +54,13 @@ export class UpdateIssueRequestDto {
@ArrayMaxSize(50)
@IsString({ each: true })
observers?: string[];
/** Имена тегов (полный новый список); отсутствующие в справочнике проекта создаются, регистр не различается */
@IsOptional()
@IsArray()
@ArrayMaxSize(ISSUE_TAGS_MAX_COUNT)
@IsString({ each: true })
@Matches(/\S/, { each: true, message: 'tags не может содержать пустых имён' })
@MaxLength(TAG_NAME_MAX_LENGTH, { each: true })
tags?: string[];
}
@@ -8,11 +8,14 @@ import {
IsNotEmpty,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
import { ISSUE_TAGS_MAX_COUNT } from '../../domain/value-objects/issue-tags.vo.js';
import { TAG_NAME_MAX_LENGTH } from '../../../tag/domain/value-objects/tag-name.vo.js';
@InputType()
export class CreateIssueInput {
@@ -65,4 +68,13 @@ export class CreateIssueInput {
@ArrayMaxSize(50)
@IsString({ each: true })
observers?: string[];
@Field(() => [String], { nullable: true })
@IsOptional()
@IsArray()
@ArrayMaxSize(ISSUE_TAGS_MAX_COUNT)
@IsString({ each: true })
@Matches(/\S/, { each: true, message: 'tags не может содержать пустых имён' })
@MaxLength(TAG_NAME_MAX_LENGTH, { each: true })
tags?: string[];
}
@@ -1,4 +1,5 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { IssueTagType } from './issue-tag.type.js';
import { UserType } from '../../../user/presentation/graphql/user.type.js';
@ObjectType('IssueDetail')
@@ -33,6 +34,9 @@ export class IssueDetailType {
@Field(() => [String])
observerIds: string[];
@Field(() => [IssueTagType])
tags: IssueTagType[];
@Field(() => UserType, { nullable: true })
assignee: UserType | null;
@@ -0,0 +1,13 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('IssueTag')
export class IssueTagType {
@Field()
id: string;
@Field()
name: string;
@Field()
color: string;
}
@@ -1,4 +1,5 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
import { IssueTagType } from './issue-tag.type.js';
@ObjectType('Issue')
export class IssueType {
@@ -32,6 +33,9 @@ export class IssueType {
@Field(() => [String])
observerIds: string[];
@Field(() => [IssueTagType])
tags: IssueTagType[];
@Field()
projectId: string;
@@ -1,7 +1,21 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { ArrayMaxSize, IsArray, IsDate, IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import {
ArrayMaxSize,
IsArray,
IsDate,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
} from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js';
import { ISSUE_TAGS_MAX_COUNT } from '../../domain/value-objects/issue-tags.vo.js';
import { TAG_NAME_MAX_LENGTH } from '../../../tag/domain/value-objects/tag-name.vo.js';
@InputType()
export class UpdateIssueInput {
@@ -49,4 +63,13 @@ export class UpdateIssueInput {
@ArrayMaxSize(50)
@IsString({ each: true })
observers?: string[];
@Field(() => [String], { nullable: true })
@IsOptional()
@IsArray()
@ArrayMaxSize(ISSUE_TAGS_MAX_COUNT)
@IsString({ each: true })
@Matches(/\S/, { each: true, message: 'tags не может содержать пустых имён' })
@MaxLength(TAG_NAME_MAX_LENGTH, { each: true })
tags?: string[];
}
@@ -119,6 +119,7 @@ export class IssueResolver {
input.dueDate,
input.type,
input.observers,
input.tags,
),
),
);
@@ -143,6 +144,7 @@ export class IssueResolver {
input.dueDate,
input.type,
input.observers ?? undefined,
input.tags ?? undefined,
),
),
);
@@ -0,0 +1,8 @@
export class TagDto {
id: string;
projectId: string;
name: string;
color: string;
createdAt: Date;
updatedAt: Date;
}
@@ -0,0 +1,15 @@
import { Tag } from '../../domain/entities/tag.entity.js';
import { TagDto } from '../dto/tag.dto.js';
export class TagApplicationMapper {
static toDto(tag: Tag): TagDto {
return {
id: tag.id as string,
projectId: tag.projectId,
name: tag.name,
color: tag.color,
createdAt: tag.createdAt,
updatedAt: tag.updatedAt,
};
}
}
@@ -0,0 +1,55 @@
import { Inject, Injectable } from '@nestjs/common';
import {
PROJECT_REPOSITORY,
type IProjectRepository,
} from '../../../project/domain/repositories/project.repository.interface.js';
import { TagProjectNotFoundException } from '../../domain/exceptions/tag-project-not-found.exception.js';
import { TagEditForbiddenException } from '../../domain/exceptions/tag-edit-forbidden.exception.js';
import { TagNotFoundException } from '../../domain/exceptions/tag-not-found.exception.js';
import { TAG_REPOSITORY, type ITagRepository } from '../../domain/repositories/tag.repository.interface.js';
import { Tag } from '../../domain/entities/tag.entity.js';
/**
* Теги проекта видят все, кому виден проект. Менять справочник могут
* владелец и участники — те же, кто создаёт задачи; наблюдатель проекта — нет.
*/
@Injectable()
export class TagAccessService {
constructor(
@Inject(PROJECT_REPOSITORY)
private readonly projectRepository: IProjectRepository,
@Inject(TAG_REPOSITORY)
private readonly tagRepository: ITagRepository,
) {}
/** Чужой проект неотличим от несуществующего */
async assertCanView(projectId: string, userId: string): Promise<void> {
const role = await this.projectRepository.findRole(projectId, userId);
if (!role) {
throw new TagProjectNotFoundException(projectId);
}
}
async assertCanManage(projectId: string, userId: string): Promise<void> {
const role = await this.projectRepository.findRole(projectId, userId);
if (!role) {
throw new TagProjectNotFoundException(projectId);
}
if (role === 'OBSERVER') {
throw new TagEditForbiddenException(projectId);
}
}
/** Тег, который пользователь может менять; тег чужого проекта неотличим от несуществующего */
async findManageable(tagId: string, userId: string): Promise<Tag> {
const tag = await this.tagRepository.findById(tagId);
const role = tag ? await this.projectRepository.findRole(tag.projectId, userId) : null;
if (!tag || !role) {
throw new TagNotFoundException(tagId);
}
if (role === 'OBSERVER') {
throw new TagEditForbiddenException(tag.projectId);
}
return tag;
}
}
@@ -0,0 +1,8 @@
export class CreateTagCommand {
constructor(
public readonly projectId: string,
public readonly userId: string,
public readonly name: string,
public readonly color?: string,
) {}
}
@@ -0,0 +1,26 @@
import { Inject, Injectable } from '@nestjs/common';
import { Tag } from '../../../domain/entities/tag.entity.js';
import { TAG_REPOSITORY, type ITagRepository } from '../../../domain/repositories/tag.repository.interface.js';
import { TagAccessService } from '../../services/tag-access.service.js';
import { TagApplicationMapper } from '../../mappers/tag-application.mapper.js';
import { TagDto } from '../../dto/tag.dto.js';
import { CreateTagCommand } from './create-tag.command.js';
/** Явное создание тега — например, сразу с цветом. Задаче теги назначаются по имени и создаются сами. */
@Injectable()
export class CreateTagUseCase {
constructor(
@Inject(TAG_REPOSITORY)
private readonly tagRepository: ITagRepository,
private readonly tagAccess: TagAccessService,
) {}
async execute(command: CreateTagCommand): Promise<TagDto> {
const draft = Tag.create({ projectId: command.projectId, name: command.name, color: command.color });
await this.tagAccess.assertCanManage(command.projectId, command.userId);
const tag = await this.tagRepository.create(draft);
return TagApplicationMapper.toDto(tag);
}
}
@@ -0,0 +1,6 @@
export class DeleteTagCommand {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}
@@ -0,0 +1,19 @@
import { Inject, Injectable } from '@nestjs/common';
import { TAG_REPOSITORY, type ITagRepository } from '../../../domain/repositories/tag.repository.interface.js';
import { TagAccessService } from '../../services/tag-access.service.js';
import { DeleteTagCommand } from './delete-tag.command.js';
/** Тег снимается со всех задач проекта; в историю задач это не пишется */
@Injectable()
export class DeleteTagUseCase {
constructor(
@Inject(TAG_REPOSITORY)
private readonly tagRepository: ITagRepository,
private readonly tagAccess: TagAccessService,
) {}
async execute(command: DeleteTagCommand): Promise<void> {
await this.tagAccess.findManageable(command.id, command.userId);
await this.tagRepository.delete(command.id);
}
}
@@ -0,0 +1,9 @@
export class ListProjectTagsQuery {
constructor(
public readonly projectId: string,
public readonly userId: string,
/** Подстрока имени — для автоподстановки */
public readonly query?: string,
public readonly limit: number = 100,
) {}
}
@@ -0,0 +1,27 @@
import { Inject, Injectable } from '@nestjs/common';
import { TAG_REPOSITORY, type ITagRepository } from '../../../domain/repositories/tag.repository.interface.js';
import { TagAccessService } from '../../services/tag-access.service.js';
import { TagApplicationMapper } from '../../mappers/tag-application.mapper.js';
import { TagDto } from '../../dto/tag.dto.js';
import { ListProjectTagsQuery } from './list-project-tags.query.js';
@Injectable()
export class ListProjectTagsUseCase {
constructor(
@Inject(TAG_REPOSITORY)
private readonly tagRepository: ITagRepository,
private readonly tagAccess: TagAccessService,
) {}
async execute(query: ListProjectTagsQuery): Promise<TagDto[]> {
await this.tagAccess.assertCanView(query.projectId, query.userId);
const tags = await this.tagRepository.findByProject({
projectId: query.projectId,
query: query.query,
limit: query.limit,
});
return tags.map(TagApplicationMapper.toDto);
}
}
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { TagAccessService } from '../services/tag-access.service.js';
import { ListProjectTagsUseCase } from './list-project-tags/list-project-tags.use-case.js';
import { CreateTagUseCase } from './create-tag/create-tag.use-case.js';
import { UpdateTagUseCase } from './update-tag/update-tag.use-case.js';
import { DeleteTagUseCase } from './delete-tag/delete-tag.use-case.js';
import { ListProjectTagsQuery } from './list-project-tags/list-project-tags.query.js';
import { CreateTagCommand } from './create-tag/create-tag.command.js';
import { UpdateTagCommand } from './update-tag/update-tag.command.js';
import { DeleteTagCommand } from './delete-tag/delete-tag.command.js';
import { TagNotFoundException } from '../../domain/exceptions/tag-not-found.exception.js';
import { TagProjectNotFoundException } from '../../domain/exceptions/tag-project-not-found.exception.js';
import { TagEditForbiddenException } from '../../domain/exceptions/tag-edit-forbidden.exception.js';
import type { ProjectRole } from '../../../project/domain/repositories/project.repository.interface.js';
import { buildTag, mockProjectRepository, mockTagRepository } from './test-helpers.js';
const setup = (role: ProjectRole | null) => {
const tagRepository = mockTagRepository([buildTag({ id: 'tag-1', name: 'backend' })]);
const access = new TagAccessService(mockProjectRepository(role), tagRepository);
return {
tagRepository,
list: new ListProjectTagsUseCase(tagRepository, access),
create: new CreateTagUseCase(tagRepository, access),
update: new UpdateTagUseCase(tagRepository, access),
remove: new DeleteTagUseCase(tagRepository, access),
};
};
describe('tag use cases', () => {
describe('ListProjectTagsUseCase', () => {
it('lets a project observer see the tags', async () => {
const { list, tagRepository } = setup('OBSERVER');
const tags = await list.execute(new ListProjectTagsQuery('project-1', 'user-1', 'back', 20));
expect(tags.map((tag) => tag.name)).toEqual(['backend']);
expect(tagRepository.findByProject).toHaveBeenCalledWith({ projectId: 'project-1', query: 'back', limit: 20 });
});
it('hides the tags of a project the user cannot see', async () => {
const { list } = setup(null);
await expect(list.execute(new ListProjectTagsQuery('project-1', 'user-1'))).rejects.toBeInstanceOf(
TagProjectNotFoundException,
);
});
});
describe('CreateTagUseCase', () => {
it('creates a tag with a color', async () => {
const { create, tagRepository } = setup('MEMBER');
const tag = await create.execute(new CreateTagCommand('project-1', 'user-1', 'ui', 'warning'));
expect(tag).toMatchObject({ projectId: 'project-1', name: 'ui', color: 'warning' });
expect(tagRepository.create).toHaveBeenCalledTimes(1);
});
it('forbids a project observer from creating tags', async () => {
const { create, tagRepository } = setup('OBSERVER');
await expect(create.execute(new CreateTagCommand('project-1', 'user-1', 'ui'))).rejects.toBeInstanceOf(
TagEditForbiddenException,
);
expect(tagRepository.create).not.toHaveBeenCalled();
});
});
describe('UpdateTagUseCase', () => {
it('renames and recolors a tag', async () => {
const { update, tagRepository } = setup('OWNER');
const tag = await update.execute(new UpdateTagCommand('tag-1', 'user-1', 'Backend API', 'info'));
expect(tag).toMatchObject({ id: 'tag-1', name: 'Backend API', color: 'info' });
expect(tagRepository.update).toHaveBeenCalledTimes(1);
});
it('treats a tag of a foreign project as missing', async () => {
const { update } = setup(null);
await expect(update.execute(new UpdateTagCommand('tag-1', 'user-1', 'x'))).rejects.toBeInstanceOf(
TagNotFoundException,
);
});
it('throws for an unknown tag', async () => {
const { update } = setup('OWNER');
await expect(update.execute(new UpdateTagCommand('nope', 'user-1', 'x'))).rejects.toBeInstanceOf(
TagNotFoundException,
);
});
});
describe('DeleteTagUseCase', () => {
it('deletes a tag', async () => {
const { remove, tagRepository } = setup('MEMBER');
await remove.execute(new DeleteTagCommand('tag-1', 'user-1'));
expect(tagRepository.delete).toHaveBeenCalledWith('tag-1');
});
it('forbids a project observer from deleting tags', async () => {
const { remove, tagRepository } = setup('OBSERVER');
await expect(remove.execute(new DeleteTagCommand('tag-1', 'user-1'))).rejects.toBeInstanceOf(
TagEditForbiddenException,
);
expect(tagRepository.delete).not.toHaveBeenCalled();
});
});
});
@@ -0,0 +1,60 @@
import { vi } from 'vitest';
import type { ITagRepository } from '../../domain/repositories/tag.repository.interface.js';
import type {
IProjectRepository,
ProjectRole,
} from '../../../project/domain/repositories/project.repository.interface.js';
import { Tag } from '../../domain/entities/tag.entity.js';
export const buildTag = (overrides: Partial<{ id: string; projectId: string; name: string; color: string }> = {}) =>
Tag.reconstitute({
id: overrides.id ?? 'tag-1',
projectId: overrides.projectId ?? 'project-1',
name: overrides.name ?? 'backend',
color: overrides.color ?? 'default',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
/**
* Репозиторий тегов в памяти: findOrCreateByNames создаёт недостающие теги
* с id `tag-<имя в нижнем регистре>` и запоминает их.
*/
export const mockTagRepository = (initial: Tag[] = []) => {
const tags = [...initial];
const repository: ITagRepository = {
findById: vi.fn(async (id: string) => tags.find((tag) => tag.id === id) ?? null),
findByProject: vi.fn(async ({ projectId }: { projectId: string }) =>
tags.filter((tag) => tag.projectId === projectId),
),
findOrCreateByNames: vi.fn(async (projectId: string, names: string[]) => {
const result = new Map<string, Tag>();
for (const name of names) {
const draft = Tag.create({ projectId, name });
let tag = tags.find((t) => t.projectId === projectId && t.normalizedName === draft.normalizedName);
if (!tag) {
tag = buildTag({ id: `tag-${draft.normalizedName}`, projectId, name: draft.name });
tags.push(tag);
}
result.set(tag.id as string, tag);
}
return [...result.values()];
}),
create: vi.fn(async (tag: Tag) =>
buildTag({ id: 'tag-new', projectId: tag.projectId, name: tag.name, color: tag.color }),
),
update: vi.fn(),
delete: vi.fn(),
};
return repository;
};
export const mockProjectRepository = (role: ProjectRole | null = 'MEMBER'): IProjectRepository => ({
findById: vi.fn(),
findAll: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findRole: vi.fn(async () => role),
addMember: vi.fn(),
});
@@ -0,0 +1,8 @@
export class UpdateTagCommand {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly name?: string,
public readonly color?: string,
) {}
}
@@ -0,0 +1,31 @@
import { Inject, Injectable } from '@nestjs/common';
import { TAG_REPOSITORY, type ITagRepository } from '../../../domain/repositories/tag.repository.interface.js';
import { TagAccessService } from '../../services/tag-access.service.js';
import { TagApplicationMapper } from '../../mappers/tag-application.mapper.js';
import { TagDto } from '../../dto/tag.dto.js';
import { UpdateTagCommand } from './update-tag.command.js';
@Injectable()
export class UpdateTagUseCase {
constructor(
@Inject(TAG_REPOSITORY)
private readonly tagRepository: ITagRepository,
private readonly tagAccess: TagAccessService,
) {}
async execute(command: UpdateTagCommand): Promise<TagDto> {
const tag = await this.tagAccess.findManageable(command.id, command.userId);
if (command.name !== undefined) {
tag.rename(command.name);
}
if (command.color !== undefined) {
tag.changeColor(command.color);
}
await this.tagRepository.update(tag);
return TagApplicationMapper.toDto(tag);
}
}
@@ -0,0 +1,36 @@
import { describe, expect, it } from 'vitest';
import { Tag } from './tag.entity.js';
describe('Tag', () => {
it('creates a new tag with the default color', () => {
const tag = Tag.create({ projectId: 'project-1', name: ' Backend ' });
expect(tag.id).toBeNull();
expect(tag.name).toBe('Backend');
expect(tag.normalizedName).toBe('backend');
expect(tag.color).toBe('default');
});
it('renames and recolors the tag, touching updatedAt', () => {
const tag = Tag.reconstitute({
id: 'tag-1',
projectId: 'project-1',
name: 'backend',
color: 'default',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
tag.rename('API');
tag.changeColor('error');
expect(tag.name).toBe('API');
expect(tag.normalizedName).toBe('api');
expect(tag.color).toBe('error');
expect(tag.updatedAt.getTime()).toBeGreaterThan(tag.createdAt.getTime());
});
it('rejects an invalid color', () => {
expect(() => Tag.create({ projectId: 'project-1', name: 'ui', color: 'pink' })).toThrow();
});
});
@@ -0,0 +1,94 @@
import { TagName } from '../value-objects/tag-name.vo.js';
import { TagColor } from '../value-objects/tag-color.vo.js';
interface TagProps {
id: string | null;
projectId: string;
name: TagName;
color: TagColor;
createdAt: Date;
updatedAt: Date;
}
/**
* Тег из справочника проекта. Имя уникально в проекте без учёта регистра —
* это гарантирует БД (см. TagNameAlreadyExistsException).
*
* `id` — UUID, генерируется на уровне Prisma при сохранении, поэтому
* у нового тега он равен null до сохранения в репозитории.
*/
export class Tag {
private constructor(private readonly props: TagProps) {}
static create(params: { projectId: string; name: string; color?: string }): Tag {
return new Tag({
id: null,
projectId: params.projectId,
name: TagName.create(params.name),
color: params.color !== undefined ? TagColor.create(params.color) : TagColor.default(),
createdAt: new Date(),
updatedAt: new Date(),
});
}
static reconstitute(props: {
id: string;
projectId: string;
name: string;
color: string;
createdAt: Date;
updatedAt: Date;
}): Tag {
return new Tag({
id: props.id,
projectId: props.projectId,
name: TagName.create(props.name),
color: TagColor.create(props.color),
createdAt: props.createdAt,
updatedAt: props.updatedAt,
});
}
get id(): string | null {
return this.props.id;
}
get projectId(): string {
return this.props.projectId;
}
get name(): string {
return this.props.name.value;
}
get normalizedName(): string {
return this.props.name.normalized;
}
get color(): string {
return this.props.color.value;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
/** Переименование меняет имя на всех задачах; в истории задач остаётся прежнее */
rename(name: string): void {
this.props.name = TagName.create(name);
this.touch();
}
changeColor(color: string): void {
this.props.color = TagColor.create(color);
this.touch();
}
private touch(): void {
this.props.updatedAt = new Date();
}
}
@@ -0,0 +1,6 @@
export class TagEditForbiddenException extends Error {
constructor(projectId: string) {
super(`Наблюдатель проекта "${projectId}" не может менять его теги`);
this.name = 'TagEditForbiddenException';
}
}
@@ -0,0 +1,6 @@
export class TagNameAlreadyExistsException extends Error {
constructor(name: string) {
super(`Тег "${name}" в проекте уже есть`);
this.name = 'TagNameAlreadyExistsException';
}
}
@@ -0,0 +1,6 @@
export class TagNotFoundException extends Error {
constructor(id: string) {
super(`Тег с id "${id}" не найден`);
this.name = 'TagNotFoundException';
}
}
@@ -0,0 +1,6 @@
export class TagProjectNotFoundException extends Error {
constructor(projectId: string) {
super(`Проект с id "${projectId}" не найден`);
this.name = 'TagProjectNotFoundException';
}
}
@@ -0,0 +1,22 @@
import { Tag } from '../entities/tag.entity.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const TAG_REPOSITORY = Symbol('TAG_REPOSITORY');
/** Права на тег проверяет вызывающий use-case — репозиторий о пользователях не знает */
export interface ITagRepository {
findById(id: string): Promise<Tag | null>;
/** Теги проекта по имени; query — подстрока имени без учёта регистра (автоподстановка) */
findByProject(params: { projectId: string; query?: string; limit: number }): Promise<Tag[]>;
/**
* Теги проекта с данными именами (без учёта регистра); отсутствующие создаются.
* Порядок — как у имён, повторы схлопываются.
*/
findOrCreateByNames(projectId: string, names: string[]): Promise<Tag[]>;
/** @throws TagNameAlreadyExistsException */
create(tag: Tag): Promise<Tag>;
/** @throws TagNameAlreadyExistsException */
update(tag: Tag): Promise<void>;
/** Снимает тег со всех задач */
delete(id: string): Promise<void>;
}
@@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { TagColor, TAG_COLORS } from './tag-color.vo.js';
describe('TagColor', () => {
it.each(TAG_COLORS)('accepts the palette color "%s"', (color) => {
expect(TagColor.create(color).value).toBe(color);
});
it('throws for a color outside the palette', () => {
expect(() => TagColor.create('#ff0000')).toThrow(/Недопустимый цвет тега/);
});
it('defaults to "default"', () => {
expect(TagColor.default().value).toBe('default');
});
});
@@ -0,0 +1,24 @@
/** Палитра — те же имена цветов, что у статусов и типов задач; как их рисовать, решает клиент */
export const TAG_COLORS = ['default', 'primary', 'secondary', 'info', 'success', 'warning', 'error'] as const;
export type TagColorValue = (typeof TAG_COLORS)[number];
export class TagColor {
private constructor(private readonly _value: TagColorValue) {}
static create(value: string): TagColor {
if (!TAG_COLORS.includes(value as TagColorValue)) {
throw new Error(`Недопустимый цвет тега: "${value}". Разрешены: ${TAG_COLORS.join(', ')}`);
}
return new TagColor(value as TagColorValue);
}
static default(): TagColor {
return new TagColor('default');
}
get value(): TagColorValue {
return this._value;
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { TagName, TAG_NAME_MAX_LENGTH } from './tag-name.vo.js';
describe('TagName', () => {
it('trims the name and keeps its case', () => {
expect(TagName.create(' Backend ').value).toBe('Backend');
});
it('normalizes to lower case for uniqueness', () => {
expect(TagName.create('Backend').normalized).toBe('backend');
expect(TagName.create('UI').equals(TagName.create('ui'))).toBe(true);
});
it('throws for an empty name', () => {
expect(() => TagName.create(' ')).toThrow(/не может быть пустым/);
});
it('throws for a too long name', () => {
expect(() => TagName.create('a'.repeat(TAG_NAME_MAX_LENGTH + 1))).toThrow(/не может превышать/);
});
});
@@ -0,0 +1,32 @@
export const TAG_NAME_MAX_LENGTH = 50;
export class TagName {
private constructor(private readonly _value: string) {}
static create(value: string): TagName {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error('Имя тега не может быть пустым');
}
if (trimmed.length > TAG_NAME_MAX_LENGTH) {
throw new Error(`Имя тега не может превышать ${TAG_NAME_MAX_LENGTH} символов`);
}
return new TagName(trimmed);
}
get value(): string {
return this._value;
}
/** Ключ уникальности в проекте: теги «UI» и «ui» — один тег */
get normalized(): string {
return this._value.toLowerCase();
}
equals(other: TagName): boolean {
return this.normalized === other.normalized;
}
}
@@ -0,0 +1,35 @@
import type { Tag as PrismaTag, Prisma } from '../../../../../../generated/prisma/client.js';
import { Tag } from '../../../../domain/entities/tag.entity.js';
export class TagPrismaMapper {
static toDomain(record: PrismaTag): Tag {
return Tag.reconstitute({
id: record.id,
projectId: record.projectId,
name: record.name,
color: record.color,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
static toCreateInput(tag: Tag): Prisma.TagUncheckedCreateInput {
return {
projectId: tag.projectId,
name: tag.name,
normalizedName: tag.normalizedName,
color: tag.color,
createdAt: tag.createdAt,
updatedAt: tag.updatedAt,
};
}
static toUpdateInput(tag: Tag): Prisma.TagUncheckedUpdateInput {
return {
name: tag.name,
normalizedName: tag.normalizedName,
color: tag.color,
updatedAt: tag.updatedAt,
};
}
}
@@ -0,0 +1,88 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { Prisma } from '../../../../../../generated/prisma/client.js';
import { Tag } from '../../../../domain/entities/tag.entity.js';
import { ITagRepository } from '../../../../domain/repositories/tag.repository.interface.js';
import { TagNameAlreadyExistsException } from '../../../../domain/exceptions/tag-name-already-exists.exception.js';
import { TagPrismaMapper } from '../mappers/tag.mapper.js';
const UNIQUE_CONSTRAINT_VIOLATION = 'P2002';
function isUniqueViolation(error: unknown): boolean {
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === UNIQUE_CONSTRAINT_VIOLATION;
}
@Injectable()
export class TagPrismaRepository implements ITagRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: string): Promise<Tag | null> {
const record = await this.prisma.tag.findUnique({ where: { id } });
return record ? TagPrismaMapper.toDomain(record) : null;
}
async findByProject(params: { projectId: string; query?: string; limit: number }): Promise<Tag[]> {
const query = params.query?.trim().toLowerCase();
const records = await this.prisma.tag.findMany({
where: { projectId: params.projectId, ...(query ? { normalizedName: { contains: query } } : {}) },
orderBy: { normalizedName: 'asc' },
take: Math.max(1, params.limit),
});
return records.map(TagPrismaMapper.toDomain);
}
async findOrCreateByNames(projectId: string, names: string[]): Promise<Tag[]> {
const drafts = new Map<string, Tag>();
for (const name of names) {
const draft = Tag.create({ projectId, name });
if (!drafts.has(draft.normalizedName)) drafts.set(draft.normalizedName, draft);
}
if (drafts.size === 0) return [];
// Уже существующие теги (в том числе созданные параллельным запросом) пропускаются
await this.prisma.tag.createMany({
data: [...drafts.values()].map(TagPrismaMapper.toCreateInput),
skipDuplicates: true,
});
const records = await this.prisma.tag.findMany({
where: { projectId, normalizedName: { in: [...drafts.keys()] } },
});
const byName = new Map(records.map((record) => [record.normalizedName, record]));
return [...drafts.keys()].flatMap((normalizedName) => {
const record = byName.get(normalizedName);
return record ? [TagPrismaMapper.toDomain(record)] : [];
});
}
async create(tag: Tag): Promise<Tag> {
try {
const record = await this.prisma.tag.create({ data: TagPrismaMapper.toCreateInput(tag) });
return TagPrismaMapper.toDomain(record);
} catch (error) {
if (isUniqueViolation(error)) {
throw new TagNameAlreadyExistsException(tag.name);
}
throw error;
}
}
async update(tag: Tag): Promise<void> {
try {
await this.prisma.tag.update({
where: { id: tag.id as string },
data: TagPrismaMapper.toUpdateInput(tag),
});
} catch (error) {
if (isUniqueViolation(error)) {
throw new TagNameAlreadyExistsException(tag.name);
}
throw error;
}
}
async delete(id: string): Promise<void> {
await this.prisma.tag.delete({ where: { id } });
}
}
@@ -0,0 +1,23 @@
import { ConflictException, ForbiddenException, NotFoundException } from '@nestjs/common';
import { TagNotFoundException } from '../../domain/exceptions/tag-not-found.exception.js';
import { TagProjectNotFoundException } from '../../domain/exceptions/tag-project-not-found.exception.js';
import { TagEditForbiddenException } from '../../domain/exceptions/tag-edit-forbidden.exception.js';
import { TagNameAlreadyExistsException } from '../../domain/exceptions/tag-name-already-exists.exception.js';
/** Транслирует доменные исключения в HTTP/GraphQL-ошибку — это забота presentation-слоя */
export async function handleTagErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof TagNotFoundException || error instanceof TagProjectNotFoundException) {
throw new NotFoundException(error.message);
}
if (error instanceof TagEditForbiddenException) {
throw new ForbiddenException(error.message);
}
if (error instanceof TagNameAlreadyExistsException) {
throw new ConflictException(error.message);
}
throw error;
}
}
@@ -0,0 +1,56 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post, Query } from '@nestjs/common';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { ListProjectTagsUseCase } from '../../application/use-cases/list-project-tags/list-project-tags.use-case.js';
import { CreateTagUseCase } from '../../application/use-cases/create-tag/create-tag.use-case.js';
import { UpdateTagUseCase } from '../../application/use-cases/update-tag/update-tag.use-case.js';
import { DeleteTagUseCase } from '../../application/use-cases/delete-tag/delete-tag.use-case.js';
import { ListProjectTagsQuery } from '../../application/use-cases/list-project-tags/list-project-tags.query.js';
import { CreateTagCommand } from '../../application/use-cases/create-tag/create-tag.command.js';
import { UpdateTagCommand } from '../../application/use-cases/update-tag/update-tag.command.js';
import { DeleteTagCommand } from '../../application/use-cases/delete-tag/delete-tag.command.js';
import { CreateTagRequestDto } from '../dto/create-tag.dto.js';
import { UpdateTagRequestDto } from '../dto/update-tag.dto.js';
import { ListTagsQueryDto } from '../dto/list-tags-query.dto.js';
import { handleTagErrors } from './tag-error-handler.js';
/** Справочник тегов проекта. Задаче теги назначаются через PATCH /issues/:id (tags — имена). */
@Controller()
export class TagController {
constructor(
private readonly listProjectTagsUseCase: ListProjectTagsUseCase,
private readonly createTagUseCase: CreateTagUseCase,
private readonly updateTagUseCase: UpdateTagUseCase,
private readonly deleteTagUseCase: DeleteTagUseCase,
) {}
@Get('projects/:projectId/tags')
async list(@Param('projectId') projectId: string, @Query() query: ListTagsQueryDto, @Session() session: UserSession) {
return handleTagErrors(() =>
this.listProjectTagsUseCase.execute(new ListProjectTagsQuery(projectId, session.user.id, query.q, query.limit)),
);
}
@Post('projects/:projectId/tags')
async create(
@Param('projectId') projectId: string,
@Body() dto: CreateTagRequestDto,
@Session() session: UserSession,
) {
return handleTagErrors(() =>
this.createTagUseCase.execute(new CreateTagCommand(projectId, session.user.id, dto.name, dto.color)),
);
}
@Patch('tags/:id')
async update(@Param('id') id: string, @Body() dto: UpdateTagRequestDto, @Session() session: UserSession) {
return handleTagErrors(() =>
this.updateTagUseCase.execute(new UpdateTagCommand(id, session.user.id, dto.name, dto.color)),
);
}
@Delete('tags/:id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id') id: string, @Session() session: UserSession) {
await handleTagErrors(() => this.deleteTagUseCase.execute(new DeleteTagCommand(id, session.user.id)));
}
}
@@ -0,0 +1,14 @@
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
import { TAG_COLORS } from '../../domain/value-objects/tag-color.vo.js';
export class CreateTagRequestDto {
@IsString()
@Matches(/\S/, { message: 'name не может быть пустым' })
@MaxLength(TAG_NAME_MAX_LENGTH)
name: string;
@IsOptional()
@IsIn(TAG_COLORS)
color?: string;
}
@@ -0,0 +1,14 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { IntDecorator } from '../../../../common/decorators/validators.js';
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
export class ListTagsQueryDto {
/** Подстрока имени без учёта регистра — для автоподстановки */
@IsOptional()
@IsString()
@MaxLength(TAG_NAME_MAX_LENGTH)
q?: string;
@IntDecorator({ isOptional: true, min: 1, max: 500 })
limit: number = 100;
}
@@ -0,0 +1,15 @@
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
import { TAG_COLORS } from '../../domain/value-objects/tag-color.vo.js';
export class UpdateTagRequestDto {
@IsOptional()
@IsString()
@Matches(/\S/, { message: 'name не может быть пустым' })
@MaxLength(TAG_NAME_MAX_LENGTH)
name?: string;
@IsOptional()
@IsIn(TAG_COLORS)
color?: string;
}
@@ -0,0 +1,18 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
import { TAG_COLORS } from '../../domain/value-objects/tag-color.vo.js';
@InputType()
export class CreateTagInput {
@Field()
@IsString()
@Matches(/\S/, { message: 'name не может быть пустым' })
@MaxLength(TAG_NAME_MAX_LENGTH)
name: string;
@Field({ nullable: true })
@IsOptional()
@IsIn(TAG_COLORS)
color?: string;
}
@@ -0,0 +1,22 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('Tag')
export class TagType {
@Field()
id: string;
@Field()
projectId: string;
@Field()
name: string;
@Field()
color: string;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
}
@@ -0,0 +1,19 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsIn, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { TAG_NAME_MAX_LENGTH } from '../../domain/value-objects/tag-name.vo.js';
import { TAG_COLORS } from '../../domain/value-objects/tag-color.vo.js';
@InputType()
export class UpdateTagInput {
@Field({ nullable: true })
@IsOptional()
@IsString()
@Matches(/\S/, { message: 'name не может быть пустым' })
@MaxLength(TAG_NAME_MAX_LENGTH)
name?: string;
@Field({ nullable: true })
@IsOptional()
@IsIn(TAG_COLORS)
color?: string;
}
@@ -0,0 +1,65 @@
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { ListProjectTagsUseCase } from '../../application/use-cases/list-project-tags/list-project-tags.use-case.js';
import { CreateTagUseCase } from '../../application/use-cases/create-tag/create-tag.use-case.js';
import { UpdateTagUseCase } from '../../application/use-cases/update-tag/update-tag.use-case.js';
import { DeleteTagUseCase } from '../../application/use-cases/delete-tag/delete-tag.use-case.js';
import { ListProjectTagsQuery } from '../../application/use-cases/list-project-tags/list-project-tags.query.js';
import { CreateTagCommand } from '../../application/use-cases/create-tag/create-tag.command.js';
import { UpdateTagCommand } from '../../application/use-cases/update-tag/update-tag.command.js';
import { DeleteTagCommand } from '../../application/use-cases/delete-tag/delete-tag.command.js';
import { TagDto } from '../../application/dto/tag.dto.js';
import { TagType } from '../graphql/tag.type.js';
import { CreateTagInput } from '../graphql/create-tag.input.js';
import { UpdateTagInput } from '../graphql/update-tag.input.js';
import { handleTagErrors } from '../controllers/tag-error-handler.js';
@Resolver(() => TagType)
export class TagResolver {
constructor(
private readonly listProjectTagsUseCase: ListProjectTagsUseCase,
private readonly createTagUseCase: CreateTagUseCase,
private readonly updateTagUseCase: UpdateTagUseCase,
private readonly deleteTagUseCase: DeleteTagUseCase,
) {}
@Query(() => [TagType], { name: 'projectTags' })
list(
@Args('projectId') projectId: string,
@Args('q', { type: () => String, nullable: true }) q: string | undefined,
@Args('limit', { type: () => Int, defaultValue: 100 }) limit: number,
@Session() session: UserSession,
): Promise<TagDto[]> {
return handleTagErrors(() =>
this.listProjectTagsUseCase.execute(new ListProjectTagsQuery(projectId, session.user.id, q, limit)),
);
}
@Mutation(() => TagType, { name: 'createTag' })
create(
@Args('projectId') projectId: string,
@Args('input') input: CreateTagInput,
@Session() session: UserSession,
): Promise<TagDto> {
return handleTagErrors(() =>
this.createTagUseCase.execute(new CreateTagCommand(projectId, session.user.id, input.name, input.color)),
);
}
@Mutation(() => TagType, { name: 'updateTag' })
update(
@Args('id') id: string,
@Args('input') input: UpdateTagInput,
@Session() session: UserSession,
): Promise<TagDto> {
return handleTagErrors(() =>
this.updateTagUseCase.execute(new UpdateTagCommand(id, session.user.id, input.name, input.color)),
);
}
@Mutation(() => Boolean, { name: 'deleteTag' })
async delete(@Args('id') id: string, @Session() session: UserSession): Promise<boolean> {
await handleTagErrors(() => this.deleteTagUseCase.execute(new DeleteTagCommand(id, session.user.id)));
return true;
}
}
+34
View File
@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { ProjectModule } from '../project/project.module.js';
import { TagPrismaRepository } from './infrastructure/persistence/prisma/repositories/tag.prisma-repository.js';
import { TAG_REPOSITORY } from './domain/repositories/tag.repository.interface.js';
import { TagAccessService } from './application/services/tag-access.service.js';
import { ListProjectTagsUseCase } from './application/use-cases/list-project-tags/list-project-tags.use-case.js';
import { CreateTagUseCase } from './application/use-cases/create-tag/create-tag.use-case.js';
import { UpdateTagUseCase } from './application/use-cases/update-tag/update-tag.use-case.js';
import { DeleteTagUseCase } from './application/use-cases/delete-tag/delete-tag.use-case.js';
import { TagController } from './presentation/controllers/tag.controller.js';
import { TagResolver } from './presentation/resolvers/tag.resolver.js';
@Module({
imports: [PrismaModule, ProjectModule],
controllers: [TagController],
providers: [
TagAccessService,
ListProjectTagsUseCase,
CreateTagUseCase,
UpdateTagUseCase,
DeleteTagUseCase,
TagResolver,
{
// Domain видит только интерфейс ITagRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
provide: TAG_REPOSITORY,
useClass: TagPrismaRepository,
},
],
// Задачи назначают теги по имени (findOrCreateByNames)
exports: [TAG_REPOSITORY],
})
export class TagModule {}