From d170fc879043f9b2788ae6717ae4e67981949be0 Mon Sep 17 00:00:00 2001 From: Vadim Date: Wed, 28 Oct 2026 16:22:24 +0300 Subject: [PATCH] tags --- .../20261028120000_tag/migration.sql | 37 ++++++ prisma/schema.prisma | 38 +++++- src/app.module.ts | 2 + .../issue/application/dto/issue-tag.dto.ts | 6 + .../application/dto/issue-with-users.dto.ts | 2 + .../issue/application/dto/issue.dto.ts | 3 + .../mappers/issue-application.mapper.ts | 9 ++ .../create-issue/create-issue.command.ts | 2 + .../create-issue.use-case.spec.ts | 56 +++++++++ .../create-issue/create-issue.use-case.ts | 36 ++++-- .../update-issue/update-issue.command.ts | 2 + .../update-issue.use-case.spec.ts | 53 +++++++- .../update-issue/update-issue.use-case.ts | 12 ++ .../issue/domain/entities/issue-change.ts | 3 +- .../domain/entities/issue.entity.spec.ts | 83 +++++++++++++ .../issue/domain/entities/issue.entity.ts | 28 ++++- .../value-objects/issue-tags.vo.spec.ts | 29 +++++ .../domain/value-objects/issue-tags.vo.ts | 35 ++++++ .../prisma/mappers/issue.mapper.spec.ts | 6 + .../prisma/mappers/issue.mapper.ts | 11 +- .../repositories/issue.prisma-repository.ts | 29 +++-- src/modules/issue/issue.module.ts | 3 +- .../controllers/issue.controller.ts | 2 + .../presentation/dto/create-issue.dto.ts | 12 ++ .../presentation/dto/update-issue.dto.ts | 12 ++ .../graphql/create-issue.input.ts | 12 ++ .../presentation/graphql/issue-detail.type.ts | 4 + .../presentation/graphql/issue-tag.type.ts | 13 ++ .../issue/presentation/graphql/issue.type.ts | 4 + .../graphql/update-issue.input.ts | 25 +++- .../presentation/resolvers/issue.resolver.ts | 2 + src/modules/tag/application/dto/tag.dto.ts | 8 ++ .../mappers/tag-application.mapper.ts | 15 +++ .../services/tag-access.service.ts | 55 +++++++++ .../create-tag/create-tag.command.ts | 8 ++ .../create-tag/create-tag.use-case.ts | 26 ++++ .../delete-tag/delete-tag.command.ts | 6 + .../delete-tag/delete-tag.use-case.ts | 19 +++ .../list-project-tags.query.ts | 9 ++ .../list-project-tags.use-case.ts | 27 +++++ .../use-cases/tag-use-cases.spec.ts | 114 ++++++++++++++++++ .../tag/application/use-cases/test-helpers.ts | 60 +++++++++ .../update-tag/update-tag.command.ts | 8 ++ .../update-tag/update-tag.use-case.ts | 31 +++++ .../tag/domain/entities/tag.entity.spec.ts | 36 ++++++ src/modules/tag/domain/entities/tag.entity.ts | 94 +++++++++++++++ .../tag-edit-forbidden.exception.ts | 6 + .../tag-name-already-exists.exception.ts | 6 + .../exceptions/tag-not-found.exception.ts | 6 + .../tag-project-not-found.exception.ts | 6 + .../repositories/tag.repository.interface.ts | 22 ++++ .../domain/value-objects/tag-color.vo.spec.ts | 16 +++ .../tag/domain/value-objects/tag-color.vo.ts | 24 ++++ .../domain/value-objects/tag-name.vo.spec.ts | 21 ++++ .../tag/domain/value-objects/tag-name.vo.ts | 32 +++++ .../persistence/prisma/mappers/tag.mapper.ts | 35 ++++++ .../repositories/tag.prisma-repository.ts | 88 ++++++++++++++ .../controllers/tag-error-handler.ts | 23 ++++ .../controllers/tag.controller.ts | 56 +++++++++ .../tag/presentation/dto/create-tag.dto.ts | 14 +++ .../presentation/dto/list-tags-query.dto.ts | 14 +++ .../tag/presentation/dto/update-tag.dto.ts | 15 +++ .../presentation/graphql/create-tag.input.ts | 18 +++ .../tag/presentation/graphql/tag.type.ts | 22 ++++ .../presentation/graphql/update-tag.input.ts | 19 +++ .../presentation/resolvers/tag.resolver.ts | 65 ++++++++++ src/modules/tag/tag.module.ts | 34 ++++++ 67 files changed, 1599 insertions(+), 30 deletions(-) create mode 100644 prisma/migrations/20261028120000_tag/migration.sql create mode 100644 src/modules/issue/application/dto/issue-tag.dto.ts create mode 100644 src/modules/issue/domain/value-objects/issue-tags.vo.spec.ts create mode 100644 src/modules/issue/domain/value-objects/issue-tags.vo.ts create mode 100644 src/modules/issue/presentation/graphql/issue-tag.type.ts create mode 100644 src/modules/tag/application/dto/tag.dto.ts create mode 100644 src/modules/tag/application/mappers/tag-application.mapper.ts create mode 100644 src/modules/tag/application/services/tag-access.service.ts create mode 100644 src/modules/tag/application/use-cases/create-tag/create-tag.command.ts create mode 100644 src/modules/tag/application/use-cases/create-tag/create-tag.use-case.ts create mode 100644 src/modules/tag/application/use-cases/delete-tag/delete-tag.command.ts create mode 100644 src/modules/tag/application/use-cases/delete-tag/delete-tag.use-case.ts create mode 100644 src/modules/tag/application/use-cases/list-project-tags/list-project-tags.query.ts create mode 100644 src/modules/tag/application/use-cases/list-project-tags/list-project-tags.use-case.ts create mode 100644 src/modules/tag/application/use-cases/tag-use-cases.spec.ts create mode 100644 src/modules/tag/application/use-cases/test-helpers.ts create mode 100644 src/modules/tag/application/use-cases/update-tag/update-tag.command.ts create mode 100644 src/modules/tag/application/use-cases/update-tag/update-tag.use-case.ts create mode 100644 src/modules/tag/domain/entities/tag.entity.spec.ts create mode 100644 src/modules/tag/domain/entities/tag.entity.ts create mode 100644 src/modules/tag/domain/exceptions/tag-edit-forbidden.exception.ts create mode 100644 src/modules/tag/domain/exceptions/tag-name-already-exists.exception.ts create mode 100644 src/modules/tag/domain/exceptions/tag-not-found.exception.ts create mode 100644 src/modules/tag/domain/exceptions/tag-project-not-found.exception.ts create mode 100644 src/modules/tag/domain/repositories/tag.repository.interface.ts create mode 100644 src/modules/tag/domain/value-objects/tag-color.vo.spec.ts create mode 100644 src/modules/tag/domain/value-objects/tag-color.vo.ts create mode 100644 src/modules/tag/domain/value-objects/tag-name.vo.spec.ts create mode 100644 src/modules/tag/domain/value-objects/tag-name.vo.ts create mode 100644 src/modules/tag/infrastructure/persistence/prisma/mappers/tag.mapper.ts create mode 100644 src/modules/tag/infrastructure/persistence/prisma/repositories/tag.prisma-repository.ts create mode 100644 src/modules/tag/presentation/controllers/tag-error-handler.ts create mode 100644 src/modules/tag/presentation/controllers/tag.controller.ts create mode 100644 src/modules/tag/presentation/dto/create-tag.dto.ts create mode 100644 src/modules/tag/presentation/dto/list-tags-query.dto.ts create mode 100644 src/modules/tag/presentation/dto/update-tag.dto.ts create mode 100644 src/modules/tag/presentation/graphql/create-tag.input.ts create mode 100644 src/modules/tag/presentation/graphql/tag.type.ts create mode 100644 src/modules/tag/presentation/graphql/update-tag.input.ts create mode 100644 src/modules/tag/presentation/resolvers/tag.resolver.ts create mode 100644 src/modules/tag/tag.module.ts diff --git a/prisma/migrations/20261028120000_tag/migration.sql b/prisma/migrations/20261028120000_tag/migration.sql new file mode 100644 index 0000000..4f60de1 --- /dev/null +++ b/prisma/migrations/20261028120000_tag/migration.sql @@ -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; + diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 68d15c4..55a4578 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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. /// У задач, созданных до появления истории, строк нет — в статистику они не попадают. diff --git a/src/app.module.ts b/src/app.module.ts index 3500163..f3e162d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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], diff --git a/src/modules/issue/application/dto/issue-tag.dto.ts b/src/modules/issue/application/dto/issue-tag.dto.ts new file mode 100644 index 0000000..d1bfb78 --- /dev/null +++ b/src/modules/issue/application/dto/issue-tag.dto.ts @@ -0,0 +1,6 @@ +/** Тег задачи — из справочника проекта */ +export class IssueTagDto { + id: string; + name: string; + color: string; +} diff --git a/src/modules/issue/application/dto/issue-with-users.dto.ts b/src/modules/issue/application/dto/issue-with-users.dto.ts index b0de2bf..22ee232 100644 --- a/src/modules/issue/application/dto/issue-with-users.dto.ts +++ b/src/modules/issue/application/dto/issue-with-users.dto.ts @@ -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; diff --git a/src/modules/issue/application/dto/issue.dto.ts b/src/modules/issue/application/dto/issue.dto.ts index 2980582..7358562 100644 --- a/src/modules/issue/application/dto/issue.dto.ts +++ b/src/modules/issue/application/dto/issue.dto.ts @@ -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; diff --git a/src/modules/issue/application/mappers/issue-application.mapper.ts b/src/modules/issue/application/mappers/issue-application.mapper.ts index 9b96b14..dbba368 100644 --- a/src/modules/issue/application/mappers/issue-application.mapper.ts +++ b/src/modules/issue/application/mappers/issue-application.mapper.ts @@ -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, diff --git a/src/modules/issue/application/use-cases/create-issue/create-issue.command.ts b/src/modules/issue/application/use-cases/create-issue/create-issue.command.ts index d3fccb7..5c7e4cd 100644 --- a/src/modules/issue/application/use-cases/create-issue/create-issue.command.ts +++ b/src/modules/issue/application/use-cases/create-issue/create-issue.command.ts @@ -10,5 +10,7 @@ export class CreateIssueCommand { public readonly dueDate?: Date, public readonly type?: string, public readonly observers?: string[], + /** Имена тегов; отсутствующие в справочнике проекта создаются */ + public readonly tags?: string[], ) {} } diff --git a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts index 5847032..0b7cdfd 100644 --- a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.spec.ts @@ -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); diff --git a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts index d91c590..e171116 100644 --- a/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts +++ b/src/modules/issue/application/use-cases/create-issue/create-issue.use-case.ts @@ -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 { - 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); diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.command.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.command.ts index 08e5e3a..9ea1d28 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.command.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.command.ts @@ -11,5 +11,7 @@ export class UpdateIssueCommand { public readonly type?: string, /** Полный новый список наблюдателей */ public readonly observers?: string[], + /** Имена тегов (полный новый список); отсутствующие в справочнике проекта создаются */ + public readonly tags?: string[], ) {} } diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts index b0d666e..cf8c925 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.spec.ts @@ -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 } = {}) => 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'] })); diff --git a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts index 70b30f6..26942ae 100644 --- a/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts +++ b/src/modules/issue/application/use-cases/update-issue/update-issue.use-case.ts @@ -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); diff --git a/src/modules/issue/domain/entities/issue-change.ts b/src/modules/issue/domain/entities/issue-change.ts index d202d0d..62c699f 100644 --- a/src/modules/issue/domain/entities/issue-change.ts +++ b/src/modules/issue/domain/entities/issue-change.ts @@ -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; diff --git a/src/modules/issue/domain/entities/issue.entity.spec.ts b/src/modules/issue/domain/entities/issue.entity.spec.ts index 56bee20..5cd90ab 100644 --- a/src/modules/issue/domain/entities/issue.entity.spec.ts +++ b/src/modules/issue/domain/entities/issue.entity.spec.ts @@ -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 = []) => + 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 }]); + }); + }); }); diff --git a/src/modules/issue/domain/entities/issue.entity.ts b/src/modules/issue/domain/entities/issue.entity.ts index 0208762..a3a1b0e 100644 --- a/src/modules/issue/domain/entities/issue.entity.ts +++ b/src/modules/issue/domain/entities/issue.entity.ts @@ -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(); @@ -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()]; diff --git a/src/modules/issue/domain/value-objects/issue-tags.vo.spec.ts b/src/modules/issue/domain/value-objects/issue-tags.vo.spec.ts new file mode 100644 index 0000000..8079843 --- /dev/null +++ b/src/modules/issue/domain/value-objects/issue-tags.vo.spec.ts @@ -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([]); + }); +}); diff --git a/src/modules/issue/domain/value-objects/issue-tags.vo.ts b/src/modules/issue/domain/value-objects/issue-tags.vo.ts new file mode 100644 index 0000000..c5f15ff --- /dev/null +++ b/src/modules/issue/domain/value-objects/issue-tags.vo.ts @@ -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); + } +} diff --git a/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.spec.ts b/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.spec.ts index 735f052..a924bcd 100644 --- a/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.spec.ts +++ b/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.spec.ts @@ -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'); }); }); diff --git a/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts b/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts index 87901a6..ebd70ad 100644 --- a/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts +++ b/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts @@ -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, diff --git a/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts b/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts index 8eb58a6..1bc7519 100644 --- a/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts +++ b/src/modules/issue/infrastructure/persistence/prisma/repositories/issue.prisma-repository.ts @@ -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 { 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), }), diff --git a/src/modules/issue/issue.module.ts b/src/modules/issue/issue.module.ts index b0415f9..34075d9 100644 --- a/src/modules/issue/issue.module.ts +++ b/src/modules/issue/issue.module.ts @@ -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, diff --git a/src/modules/issue/presentation/controllers/issue.controller.ts b/src/modules/issue/presentation/controllers/issue.controller.ts index afb11e2..a7dad03 100644 --- a/src/modules/issue/presentation/controllers/issue.controller.ts +++ b/src/modules/issue/presentation/controllers/issue.controller.ts @@ -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, ), ), ); diff --git a/src/modules/issue/presentation/dto/create-issue.dto.ts b/src/modules/issue/presentation/dto/create-issue.dto.ts index 8b95260..becf3cd 100644 --- a/src/modules/issue/presentation/dto/create-issue.dto.ts +++ b/src/modules/issue/presentation/dto/create-issue.dto.ts @@ -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[]; } diff --git a/src/modules/issue/presentation/dto/update-issue.dto.ts b/src/modules/issue/presentation/dto/update-issue.dto.ts index 47a749b..fffca4f 100644 --- a/src/modules/issue/presentation/dto/update-issue.dto.ts +++ b/src/modules/issue/presentation/dto/update-issue.dto.ts @@ -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[]; } diff --git a/src/modules/issue/presentation/graphql/create-issue.input.ts b/src/modules/issue/presentation/graphql/create-issue.input.ts index 6090217..441c482 100644 --- a/src/modules/issue/presentation/graphql/create-issue.input.ts +++ b/src/modules/issue/presentation/graphql/create-issue.input.ts @@ -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[]; } diff --git a/src/modules/issue/presentation/graphql/issue-detail.type.ts b/src/modules/issue/presentation/graphql/issue-detail.type.ts index 1e11ba7..031be77 100644 --- a/src/modules/issue/presentation/graphql/issue-detail.type.ts +++ b/src/modules/issue/presentation/graphql/issue-detail.type.ts @@ -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; diff --git a/src/modules/issue/presentation/graphql/issue-tag.type.ts b/src/modules/issue/presentation/graphql/issue-tag.type.ts new file mode 100644 index 0000000..ea0ecac --- /dev/null +++ b/src/modules/issue/presentation/graphql/issue-tag.type.ts @@ -0,0 +1,13 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType('IssueTag') +export class IssueTagType { + @Field() + id: string; + + @Field() + name: string; + + @Field() + color: string; +} diff --git a/src/modules/issue/presentation/graphql/issue.type.ts b/src/modules/issue/presentation/graphql/issue.type.ts index 1daa0b7..81d0ed4 100644 --- a/src/modules/issue/presentation/graphql/issue.type.ts +++ b/src/modules/issue/presentation/graphql/issue.type.ts @@ -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; diff --git a/src/modules/issue/presentation/graphql/update-issue.input.ts b/src/modules/issue/presentation/graphql/update-issue.input.ts index 1f5590d..5c32cb1 100644 --- a/src/modules/issue/presentation/graphql/update-issue.input.ts +++ b/src/modules/issue/presentation/graphql/update-issue.input.ts @@ -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[]; } diff --git a/src/modules/issue/presentation/resolvers/issue.resolver.ts b/src/modules/issue/presentation/resolvers/issue.resolver.ts index 4d18513..e9e0fe6 100644 --- a/src/modules/issue/presentation/resolvers/issue.resolver.ts +++ b/src/modules/issue/presentation/resolvers/issue.resolver.ts @@ -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, ), ), ); diff --git a/src/modules/tag/application/dto/tag.dto.ts b/src/modules/tag/application/dto/tag.dto.ts new file mode 100644 index 0000000..ca1fc5f --- /dev/null +++ b/src/modules/tag/application/dto/tag.dto.ts @@ -0,0 +1,8 @@ +export class TagDto { + id: string; + projectId: string; + name: string; + color: string; + createdAt: Date; + updatedAt: Date; +} diff --git a/src/modules/tag/application/mappers/tag-application.mapper.ts b/src/modules/tag/application/mappers/tag-application.mapper.ts new file mode 100644 index 0000000..a1252ef --- /dev/null +++ b/src/modules/tag/application/mappers/tag-application.mapper.ts @@ -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, + }; + } +} diff --git a/src/modules/tag/application/services/tag-access.service.ts b/src/modules/tag/application/services/tag-access.service.ts new file mode 100644 index 0000000..cf90d14 --- /dev/null +++ b/src/modules/tag/application/services/tag-access.service.ts @@ -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 { + const role = await this.projectRepository.findRole(projectId, userId); + if (!role) { + throw new TagProjectNotFoundException(projectId); + } + } + + async assertCanManage(projectId: string, userId: string): Promise { + 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 { + 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; + } +} diff --git a/src/modules/tag/application/use-cases/create-tag/create-tag.command.ts b/src/modules/tag/application/use-cases/create-tag/create-tag.command.ts new file mode 100644 index 0000000..8ffe8f4 --- /dev/null +++ b/src/modules/tag/application/use-cases/create-tag/create-tag.command.ts @@ -0,0 +1,8 @@ +export class CreateTagCommand { + constructor( + public readonly projectId: string, + public readonly userId: string, + public readonly name: string, + public readonly color?: string, + ) {} +} diff --git a/src/modules/tag/application/use-cases/create-tag/create-tag.use-case.ts b/src/modules/tag/application/use-cases/create-tag/create-tag.use-case.ts new file mode 100644 index 0000000..9f00730 --- /dev/null +++ b/src/modules/tag/application/use-cases/create-tag/create-tag.use-case.ts @@ -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 { + 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); + } +} diff --git a/src/modules/tag/application/use-cases/delete-tag/delete-tag.command.ts b/src/modules/tag/application/use-cases/delete-tag/delete-tag.command.ts new file mode 100644 index 0000000..9746c1f --- /dev/null +++ b/src/modules/tag/application/use-cases/delete-tag/delete-tag.command.ts @@ -0,0 +1,6 @@ +export class DeleteTagCommand { + constructor( + public readonly id: string, + public readonly userId: string, + ) {} +} diff --git a/src/modules/tag/application/use-cases/delete-tag/delete-tag.use-case.ts b/src/modules/tag/application/use-cases/delete-tag/delete-tag.use-case.ts new file mode 100644 index 0000000..c68d2c1 --- /dev/null +++ b/src/modules/tag/application/use-cases/delete-tag/delete-tag.use-case.ts @@ -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 { + await this.tagAccess.findManageable(command.id, command.userId); + await this.tagRepository.delete(command.id); + } +} diff --git a/src/modules/tag/application/use-cases/list-project-tags/list-project-tags.query.ts b/src/modules/tag/application/use-cases/list-project-tags/list-project-tags.query.ts new file mode 100644 index 0000000..cb9ef2c --- /dev/null +++ b/src/modules/tag/application/use-cases/list-project-tags/list-project-tags.query.ts @@ -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, + ) {} +} diff --git a/src/modules/tag/application/use-cases/list-project-tags/list-project-tags.use-case.ts b/src/modules/tag/application/use-cases/list-project-tags/list-project-tags.use-case.ts new file mode 100644 index 0000000..153848c --- /dev/null +++ b/src/modules/tag/application/use-cases/list-project-tags/list-project-tags.use-case.ts @@ -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 { + 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); + } +} diff --git a/src/modules/tag/application/use-cases/tag-use-cases.spec.ts b/src/modules/tag/application/use-cases/tag-use-cases.spec.ts new file mode 100644 index 0000000..a7d1ea9 --- /dev/null +++ b/src/modules/tag/application/use-cases/tag-use-cases.spec.ts @@ -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(); + }); + }); +}); diff --git a/src/modules/tag/application/use-cases/test-helpers.ts b/src/modules/tag/application/use-cases/test-helpers.ts new file mode 100644 index 0000000..58516a9 --- /dev/null +++ b/src/modules/tag/application/use-cases/test-helpers.ts @@ -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(); + 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(), +}); diff --git a/src/modules/tag/application/use-cases/update-tag/update-tag.command.ts b/src/modules/tag/application/use-cases/update-tag/update-tag.command.ts new file mode 100644 index 0000000..6ceef96 --- /dev/null +++ b/src/modules/tag/application/use-cases/update-tag/update-tag.command.ts @@ -0,0 +1,8 @@ +export class UpdateTagCommand { + constructor( + public readonly id: string, + public readonly userId: string, + public readonly name?: string, + public readonly color?: string, + ) {} +} diff --git a/src/modules/tag/application/use-cases/update-tag/update-tag.use-case.ts b/src/modules/tag/application/use-cases/update-tag/update-tag.use-case.ts new file mode 100644 index 0000000..58e20bd --- /dev/null +++ b/src/modules/tag/application/use-cases/update-tag/update-tag.use-case.ts @@ -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 { + 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); + } +} diff --git a/src/modules/tag/domain/entities/tag.entity.spec.ts b/src/modules/tag/domain/entities/tag.entity.spec.ts new file mode 100644 index 0000000..a8e3552 --- /dev/null +++ b/src/modules/tag/domain/entities/tag.entity.spec.ts @@ -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(); + }); +}); diff --git a/src/modules/tag/domain/entities/tag.entity.ts b/src/modules/tag/domain/entities/tag.entity.ts new file mode 100644 index 0000000..73cde72 --- /dev/null +++ b/src/modules/tag/domain/entities/tag.entity.ts @@ -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(); + } +} diff --git a/src/modules/tag/domain/exceptions/tag-edit-forbidden.exception.ts b/src/modules/tag/domain/exceptions/tag-edit-forbidden.exception.ts new file mode 100644 index 0000000..c82ea75 --- /dev/null +++ b/src/modules/tag/domain/exceptions/tag-edit-forbidden.exception.ts @@ -0,0 +1,6 @@ +export class TagEditForbiddenException extends Error { + constructor(projectId: string) { + super(`Наблюдатель проекта "${projectId}" не может менять его теги`); + this.name = 'TagEditForbiddenException'; + } +} diff --git a/src/modules/tag/domain/exceptions/tag-name-already-exists.exception.ts b/src/modules/tag/domain/exceptions/tag-name-already-exists.exception.ts new file mode 100644 index 0000000..5f000ee --- /dev/null +++ b/src/modules/tag/domain/exceptions/tag-name-already-exists.exception.ts @@ -0,0 +1,6 @@ +export class TagNameAlreadyExistsException extends Error { + constructor(name: string) { + super(`Тег "${name}" в проекте уже есть`); + this.name = 'TagNameAlreadyExistsException'; + } +} diff --git a/src/modules/tag/domain/exceptions/tag-not-found.exception.ts b/src/modules/tag/domain/exceptions/tag-not-found.exception.ts new file mode 100644 index 0000000..ce3a677 --- /dev/null +++ b/src/modules/tag/domain/exceptions/tag-not-found.exception.ts @@ -0,0 +1,6 @@ +export class TagNotFoundException extends Error { + constructor(id: string) { + super(`Тег с id "${id}" не найден`); + this.name = 'TagNotFoundException'; + } +} diff --git a/src/modules/tag/domain/exceptions/tag-project-not-found.exception.ts b/src/modules/tag/domain/exceptions/tag-project-not-found.exception.ts new file mode 100644 index 0000000..d35c94d --- /dev/null +++ b/src/modules/tag/domain/exceptions/tag-project-not-found.exception.ts @@ -0,0 +1,6 @@ +export class TagProjectNotFoundException extends Error { + constructor(projectId: string) { + super(`Проект с id "${projectId}" не найден`); + this.name = 'TagProjectNotFoundException'; + } +} diff --git a/src/modules/tag/domain/repositories/tag.repository.interface.ts b/src/modules/tag/domain/repositories/tag.repository.interface.ts new file mode 100644 index 0000000..f2f84a5 --- /dev/null +++ b/src/modules/tag/domain/repositories/tag.repository.interface.ts @@ -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; + /** Теги проекта по имени; query — подстрока имени без учёта регистра (автоподстановка) */ + findByProject(params: { projectId: string; query?: string; limit: number }): Promise; + /** + * Теги проекта с данными именами (без учёта регистра); отсутствующие создаются. + * Порядок — как у имён, повторы схлопываются. + */ + findOrCreateByNames(projectId: string, names: string[]): Promise; + /** @throws TagNameAlreadyExistsException */ + create(tag: Tag): Promise; + /** @throws TagNameAlreadyExistsException */ + update(tag: Tag): Promise; + /** Снимает тег со всех задач */ + delete(id: string): Promise; +} diff --git a/src/modules/tag/domain/value-objects/tag-color.vo.spec.ts b/src/modules/tag/domain/value-objects/tag-color.vo.spec.ts new file mode 100644 index 0000000..25658f7 --- /dev/null +++ b/src/modules/tag/domain/value-objects/tag-color.vo.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/tag/domain/value-objects/tag-color.vo.ts b/src/modules/tag/domain/value-objects/tag-color.vo.ts new file mode 100644 index 0000000..d5221c6 --- /dev/null +++ b/src/modules/tag/domain/value-objects/tag-color.vo.ts @@ -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; + } +} diff --git a/src/modules/tag/domain/value-objects/tag-name.vo.spec.ts b/src/modules/tag/domain/value-objects/tag-name.vo.spec.ts new file mode 100644 index 0000000..0366105 --- /dev/null +++ b/src/modules/tag/domain/value-objects/tag-name.vo.spec.ts @@ -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(/не может превышать/); + }); +}); diff --git a/src/modules/tag/domain/value-objects/tag-name.vo.ts b/src/modules/tag/domain/value-objects/tag-name.vo.ts new file mode 100644 index 0000000..45b7064 --- /dev/null +++ b/src/modules/tag/domain/value-objects/tag-name.vo.ts @@ -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; + } +} diff --git a/src/modules/tag/infrastructure/persistence/prisma/mappers/tag.mapper.ts b/src/modules/tag/infrastructure/persistence/prisma/mappers/tag.mapper.ts new file mode 100644 index 0000000..d87ef04 --- /dev/null +++ b/src/modules/tag/infrastructure/persistence/prisma/mappers/tag.mapper.ts @@ -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, + }; + } +} diff --git a/src/modules/tag/infrastructure/persistence/prisma/repositories/tag.prisma-repository.ts b/src/modules/tag/infrastructure/persistence/prisma/repositories/tag.prisma-repository.ts new file mode 100644 index 0000000..b2e5f62 --- /dev/null +++ b/src/modules/tag/infrastructure/persistence/prisma/repositories/tag.prisma-repository.ts @@ -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 { + 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 { + 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 { + const drafts = new Map(); + 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 { + 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 { + 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 { + await this.prisma.tag.delete({ where: { id } }); + } +} diff --git a/src/modules/tag/presentation/controllers/tag-error-handler.ts b/src/modules/tag/presentation/controllers/tag-error-handler.ts new file mode 100644 index 0000000..8ef2a8f --- /dev/null +++ b/src/modules/tag/presentation/controllers/tag-error-handler.ts @@ -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(fn: () => Promise): Promise { + 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; + } +} diff --git a/src/modules/tag/presentation/controllers/tag.controller.ts b/src/modules/tag/presentation/controllers/tag.controller.ts new file mode 100644 index 0000000..d9012a5 --- /dev/null +++ b/src/modules/tag/presentation/controllers/tag.controller.ts @@ -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))); + } +} diff --git a/src/modules/tag/presentation/dto/create-tag.dto.ts b/src/modules/tag/presentation/dto/create-tag.dto.ts new file mode 100644 index 0000000..ad1c07b --- /dev/null +++ b/src/modules/tag/presentation/dto/create-tag.dto.ts @@ -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; +} diff --git a/src/modules/tag/presentation/dto/list-tags-query.dto.ts b/src/modules/tag/presentation/dto/list-tags-query.dto.ts new file mode 100644 index 0000000..361890e --- /dev/null +++ b/src/modules/tag/presentation/dto/list-tags-query.dto.ts @@ -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; +} diff --git a/src/modules/tag/presentation/dto/update-tag.dto.ts b/src/modules/tag/presentation/dto/update-tag.dto.ts new file mode 100644 index 0000000..7d910aa --- /dev/null +++ b/src/modules/tag/presentation/dto/update-tag.dto.ts @@ -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; +} diff --git a/src/modules/tag/presentation/graphql/create-tag.input.ts b/src/modules/tag/presentation/graphql/create-tag.input.ts new file mode 100644 index 0000000..20fa31b --- /dev/null +++ b/src/modules/tag/presentation/graphql/create-tag.input.ts @@ -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; +} diff --git a/src/modules/tag/presentation/graphql/tag.type.ts b/src/modules/tag/presentation/graphql/tag.type.ts new file mode 100644 index 0000000..e87cfea --- /dev/null +++ b/src/modules/tag/presentation/graphql/tag.type.ts @@ -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; +} diff --git a/src/modules/tag/presentation/graphql/update-tag.input.ts b/src/modules/tag/presentation/graphql/update-tag.input.ts new file mode 100644 index 0000000..68229e2 --- /dev/null +++ b/src/modules/tag/presentation/graphql/update-tag.input.ts @@ -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; +} diff --git a/src/modules/tag/presentation/resolvers/tag.resolver.ts b/src/modules/tag/presentation/resolvers/tag.resolver.ts new file mode 100644 index 0000000..61d5462 --- /dev/null +++ b/src/modules/tag/presentation/resolvers/tag.resolver.ts @@ -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 { + 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 { + 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 { + 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 { + await handleTagErrors(() => this.deleteTagUseCase.execute(new DeleteTagCommand(id, session.user.id))); + return true; + } +} diff --git a/src/modules/tag/tag.module.ts b/src/modules/tag/tag.module.ts new file mode 100644 index 0000000..b65ec49 --- /dev/null +++ b/src/modules/tag/tag.module.ts @@ -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 {}