diff --git a/prisma/migrations/20261018173111_issue_type/migration.sql b/prisma/migrations/20261018173111_issue_type/migration.sql new file mode 100644 index 0000000..8494b07 --- /dev/null +++ b/prisma/migrations/20261018173111_issue_type/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "issue" ADD COLUMN "type" TEXT NOT NULL DEFAULT 'TASK'; + +-- CreateIndex +CREATE INDEX "issue_projectId_type_idx" ON "issue"("projectId", "type"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 30ca167..8dd8d19 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -88,6 +88,7 @@ model Issue { content String? status String priority Int @default(0) + type String @default("TASK") assigneeId String? assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: SetNull) reporterId String? @@ -109,6 +110,7 @@ model Issue { @@index([projectId, createdAt]) @@index([dueDate]) @@index([projectId, dueDate]) + @@index([projectId, type]) @@map("issue") } diff --git a/src/app.module.ts b/src/app.module.ts index 96c05ac..e572098 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -5,6 +5,7 @@ import { IssueModule } from './modules/issue/issue.module.js'; import { ProjectModule } from './modules/project/project.module.js'; import { UserModule } from './modules/user/user.module.js'; import { StatusModule } from './modules/status/status.module.js'; +import { IssueTypeModule } from './modules/issue-type/issue-type.module.js'; import { NotificationModule } from './modules/notification/notification.module.js'; import { CommentModule } from './modules/comment/comment.module.js'; import { FileModule } from './modules/file/file.module.js'; @@ -52,6 +53,7 @@ import { join } from 'node:path'; ProjectModule, UserModule, StatusModule, + IssueTypeModule, NotificationModule, CommentModule, FileModule, diff --git a/src/modules/comment/application/use-cases/test-helpers.ts b/src/modules/comment/application/use-cases/test-helpers.ts index fdc95f1..96cf58c 100644 --- a/src/modules/comment/application/use-cases/test-helpers.ts +++ b/src/modules/comment/application/use-cases/test-helpers.ts @@ -29,6 +29,7 @@ export const buildIssue = () => content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: null, reporter: 'user-1', projectId: 'project-1', diff --git a/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts index 4cd5116..270d3c4 100644 --- a/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts +++ b/src/modules/history/application/use-cases/get-issue-history/get-issue-history.use-case.spec.ts @@ -19,6 +19,7 @@ const buildIssue = () => content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: null, reporter: 'user-1', projectId: 'project-1', diff --git a/src/modules/issue-type/application/use-cases/list-issue-types/list-issue-types.use-case.ts b/src/modules/issue-type/application/use-cases/list-issue-types/list-issue-types.use-case.ts new file mode 100644 index 0000000..ed02771 --- /dev/null +++ b/src/modules/issue-type/application/use-cases/list-issue-types/list-issue-types.use-case.ts @@ -0,0 +1,9 @@ +import { Injectable } from '@nestjs/common'; +import { ISSUE_TYPES } from '../../../../issue/domain/value-objects/issue-type.vo.js'; + +@Injectable() +export class ListIssueTypesUseCase { + execute(): typeof ISSUE_TYPES { + return ISSUE_TYPES; + } +} diff --git a/src/modules/issue-type/issue-type.module.ts b/src/modules/issue-type/issue-type.module.ts new file mode 100644 index 0000000..6f247eb --- /dev/null +++ b/src/modules/issue-type/issue-type.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { IssueTypeController } from './presentation/controllers/issue-type.controller.js'; +import { IssueTypeResolver } from './presentation/resolvers/issue-type.resolver.js'; +import { ListIssueTypesUseCase } from './application/use-cases/list-issue-types/list-issue-types.use-case.js'; + +@Module({ + controllers: [IssueTypeController], + providers: [ListIssueTypesUseCase, IssueTypeResolver], +}) +export class IssueTypeModule {} diff --git a/src/modules/issue-type/presentation/controllers/issue-type.controller.ts b/src/modules/issue-type/presentation/controllers/issue-type.controller.ts new file mode 100644 index 0000000..7c88cd3 --- /dev/null +++ b/src/modules/issue-type/presentation/controllers/issue-type.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; +import { ListIssueTypesUseCase } from '../../application/use-cases/list-issue-types/list-issue-types.use-case.js'; +import { ISSUE_TYPES } from '../../../issue/domain/value-objects/issue-type.vo.js'; + +@Controller('issue-types') +export class IssueTypeController { + constructor(private readonly listIssueTypesUseCase: ListIssueTypesUseCase) {} + + @Get() + list(): typeof ISSUE_TYPES { + return this.listIssueTypesUseCase.execute(); + } +} diff --git a/src/modules/issue-type/presentation/graphql/issue-type-definition.type.ts b/src/modules/issue-type/presentation/graphql/issue-type-definition.type.ts new file mode 100644 index 0000000..bcd34dc --- /dev/null +++ b/src/modules/issue-type/presentation/graphql/issue-type-definition.type.ts @@ -0,0 +1,14 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +/** Не `IssueType` — это имя занято value-объектом типа задачи */ +@ObjectType('IssueTypeDefinition') +export class IssueTypeDefinitionType { + @Field() + name: string; + + @Field() + alias: string; + + @Field() + color: string; +} diff --git a/src/modules/issue-type/presentation/resolvers/issue-type.resolver.ts b/src/modules/issue-type/presentation/resolvers/issue-type.resolver.ts new file mode 100644 index 0000000..1adbc1f --- /dev/null +++ b/src/modules/issue-type/presentation/resolvers/issue-type.resolver.ts @@ -0,0 +1,14 @@ +import { Query, Resolver } from '@nestjs/graphql'; +import { ListIssueTypesUseCase } from '../../application/use-cases/list-issue-types/list-issue-types.use-case.js'; +import { ISSUE_TYPES } from '../../../issue/domain/value-objects/issue-type.vo.js'; +import { IssueTypeDefinitionType } from '../graphql/issue-type-definition.type.js'; + +@Resolver() +export class IssueTypeResolver { + constructor(private readonly listIssueTypesUseCase: ListIssueTypesUseCase) {} + + @Query(() => [IssueTypeDefinitionType], { name: 'issueTypes' }) + list(): typeof ISSUE_TYPES { + return this.listIssueTypesUseCase.execute(); + } +} 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 e670b69..057f00c 100644 --- a/src/modules/issue/application/dto/issue-with-users.dto.ts +++ b/src/modules/issue/application/dto/issue-with-users.dto.ts @@ -7,6 +7,7 @@ export class IssueWithUsersDto { content: string | null; status: string; priority: number; + type: string; assigneeId: string | null; reporterId: string | null; assignee: UserDto | null; diff --git a/src/modules/issue/application/dto/issue.dto.ts b/src/modules/issue/application/dto/issue.dto.ts index 1319394..f6332c4 100644 --- a/src/modules/issue/application/dto/issue.dto.ts +++ b/src/modules/issue/application/dto/issue.dto.ts @@ -5,6 +5,7 @@ export class IssueDto { content: string | null; status: string; priority: number; + type: string; assigneeId: string | null; reporterId: string | null; projectId: string; diff --git a/src/modules/issue/application/mappers/issue-application.mapper.ts b/src/modules/issue/application/mappers/issue-application.mapper.ts index 95e951c..16edfc7 100644 --- a/src/modules/issue/application/mappers/issue-application.mapper.ts +++ b/src/modules/issue/application/mappers/issue-application.mapper.ts @@ -12,6 +12,7 @@ export class IssueApplicationMapper { content: issue.content, status: issue.status, priority: issue.priority, + type: issue.type, assigneeId: issue.assignee, reporterId: issue.reporter, projectId: issue.projectId, @@ -30,6 +31,7 @@ export class IssueApplicationMapper { content: issue.content, status: issue.status, priority: issue.priority, + type: issue.type, assigneeId: issue.assignee, reporterId: issue.reporter, assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null, diff --git a/src/modules/issue/application/services/issue-user-expander.service.spec.ts b/src/modules/issue/application/services/issue-user-expander.service.spec.ts index 1fadf98..8a34b4b 100644 --- a/src/modules/issue/application/services/issue-user-expander.service.spec.ts +++ b/src/modules/issue/application/services/issue-user-expander.service.spec.ts @@ -24,6 +24,7 @@ const buildIssue = (overrides: Partial<{ assignee: string | null; reporter: stri content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: 'assignee' in overrides ? (overrides.assignee ?? null) : 'user-2', reporter: overrides.reporter ?? 'user-1', projectId: 'project-1', 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 ff79c10..7ed4062 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 @@ -8,5 +8,6 @@ export class CreateIssueCommand { public readonly priority?: number, public readonly assignee?: string, public readonly dueDate?: Date, + public readonly type?: 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 939d241..135a82b 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 @@ -64,6 +64,7 @@ describe('CreateIssueUseCase', () => { content: issue.content, status: issue.status, priority: issue.priority, + type: issue.type, assignee: issue.assignee, reporter: issue.reporter, projectId: issue.projectId, @@ -82,6 +83,7 @@ describe('CreateIssueUseCase', () => { title: 'Fix login bug', status: 'OPEN', priority: 0, + type: 'TASK', reporterId: 'user-1', }); }); @@ -113,6 +115,7 @@ describe('CreateIssueUseCase', () => { content: issue.content, status: issue.status, priority: issue.priority, + type: issue.type, assignee: issue.assignee, reporter: issue.reporter, projectId: issue.projectId, @@ -161,4 +164,26 @@ describe('CreateIssueUseCase', () => { expect(notificationsService.notify).not.toHaveBeenCalled(); }); + + it('creates a TASK by default and accepts an explicit type', async () => { + vi.mocked(repository.create).mockImplementation(async (issue) => issue); + + const task = await useCase.execute(new CreateIssueCommand('ISSUE-4', 'Something', 'user-1', 'project-1')); + const bug = await useCase.execute( + new CreateIssueCommand( + 'ISSUE-5', + 'Crash on start', + 'user-1', + 'project-1', + undefined, + undefined, + undefined, + undefined, + 'BUG', + ), + ); + + expect(task.type).toBe('TASK'); + expect(bug.type).toBe('BUG'); + }); }); 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 a727c6b..24a6e7b 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 @@ -30,6 +30,7 @@ export class CreateIssueUseCase { title: command.title, content: command.content, priority: command.priority, + type: command.type, assignee: command.assignee, reporter: command.reporter, projectId: command.projectId, diff --git a/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts index 0cebce5..3eb4b3d 100644 --- a/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/delete-issue/delete-issue.use-case.spec.ts @@ -14,6 +14,7 @@ const buildIssue = () => content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: null, reporter: 'user-1', projectId: 'project-1', diff --git a/src/modules/issue/application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.spec.ts b/src/modules/issue/application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.spec.ts index dcd25e2..9980e93 100644 --- a/src/modules/issue/application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.spec.ts @@ -15,6 +15,7 @@ const buildIssue = () => content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: 'user-2', reporter: 'user-1', projectId: 'project-1', diff --git a/src/modules/issue/application/use-cases/get-issue/get-issue.use-case.spec.ts b/src/modules/issue/application/use-cases/get-issue/get-issue.use-case.spec.ts index 295a9cc..bd864c3 100644 --- a/src/modules/issue/application/use-cases/get-issue/get-issue.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/get-issue/get-issue.use-case.spec.ts @@ -15,6 +15,7 @@ const buildIssue = () => content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: 'user-2', reporter: 'user-1', projectId: 'project-1', diff --git a/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.spec.ts b/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.spec.ts index 6216941..25f1bf9 100644 --- a/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/list-calendar-issues/list-calendar-issues.use-case.spec.ts @@ -18,6 +18,7 @@ const buildIssue = (id: number) => content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: null, reporter: 'user-1', projectId: 'project-1', diff --git a/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts b/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts index ad424f8..0532370 100644 --- a/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts +++ b/src/modules/issue/application/use-cases/list-issues/list-issues.query.ts @@ -8,5 +8,6 @@ export class ListIssuesQuery { public readonly text?: string, public readonly assigneeId?: string, public readonly hasDueDate?: boolean, + public readonly type?: string[], ) {} } diff --git a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.spec.ts b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.spec.ts index f5498e1..f44f245 100644 --- a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.spec.ts +++ b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.spec.ts @@ -14,6 +14,7 @@ const buildIssue = (id: number) => content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: null, reporter: 'user-1', projectId: 'project-1', diff --git a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts index cf2c6a1..041f6ec 100644 --- a/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts +++ b/src/modules/issue/application/use-cases/list-issues/list-issues.use-case.ts @@ -23,6 +23,7 @@ export class ListIssuesUseCase { text: query.text, assigneeId: query.assigneeId, hasDueDate: query.hasDueDate, + type: query.type, }); return { list: await this.issueUserExpander.expandAll(list), meta }; 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 fea0ced..4ef26ac 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 @@ -8,5 +8,6 @@ export class UpdateIssueCommand { public readonly priority?: number, public readonly assignee?: string | null, public readonly dueDate?: Date | null, + public readonly type?: 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 f7b9a50..79a1261 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 @@ -19,6 +19,7 @@ const buildIssue = () => content: 'Original content', status: 'OPEN', priority: 0, + type: 'TASK', assignee: null, reporter: 'user-1', projectId: 'project-1', @@ -155,4 +156,14 @@ describe('UpdateIssueUseCase', () => { expect(notificationsService.notify).not.toHaveBeenCalled(); }); + + it('changes the issue type', async () => { + vi.mocked(repository.findById).mockResolvedValue(buildIssue()); + + const dto = await useCase.execute( + new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, undefined, undefined, 'BUG'), + ); + + expect(dto.type).toBe('BUG'); + }); }); 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 7232aa3..5f243d2 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 @@ -47,6 +47,10 @@ export class UpdateIssueUseCase { issue.changePriority(command.priority); } + if (command.type !== undefined) { + issue.changeType(command.type); + } + if (command.assignee !== undefined) { if (command.assignee === null || command.assignee === '') { issue.unassign(); diff --git a/src/modules/issue/domain/entities/issue-change.ts b/src/modules/issue/domain/entities/issue-change.ts index 19b0114..02fe3f1 100644 --- a/src/modules/issue/domain/entities/issue-change.ts +++ b/src/modules/issue/domain/entities/issue-change.ts @@ -8,6 +8,7 @@ export const ISSUE_CHANGE_FIELDS = [ 'content', 'status', 'priority', + 'type', 'assignee', 'dueDate', ] as const; diff --git a/src/modules/issue/domain/entities/issue.entity.spec.ts b/src/modules/issue/domain/entities/issue.entity.spec.ts index 098b4ff..ca95bed 100644 --- a/src/modules/issue/domain/entities/issue.entity.spec.ts +++ b/src/modules/issue/domain/entities/issue.entity.spec.ts @@ -19,6 +19,7 @@ describe('Issue', () => { expect(issue.content).toBeNull(); expect(issue.status).toBe('OPEN'); expect(issue.priority).toBe(0); + expect(issue.type).toBe('TASK'); expect(issue.assignee).toBeNull(); expect(issue.reporter).toBe('user-1'); }); @@ -31,11 +32,13 @@ describe('Issue', () => { projectId: 'project-1', content: 'Details here', priority: 3, + type: 'BUG', assignee: 'user-2', }); expect(issue.content).toBe('Details here'); expect(issue.priority).toBe(3); + expect(issue.type).toBe('BUG'); expect(issue.assignee).toBe('user-2'); }); @@ -70,6 +73,7 @@ describe('Issue', () => { content: null, status: 'IN_PROGRESS', priority: 2, + type: 'TASK', assignee: 'user-3', reporter: 'user-1', projectId: 'project-1', @@ -93,6 +97,7 @@ describe('Issue', () => { content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: null, reporter: 'user-1', projectId: 'project-1', @@ -139,6 +144,15 @@ describe('Issue', () => { expect(() => issue.changePriority(10)).toThrow(); }); + it('changes the type through the value object rules', () => { + const issue = build(); + + issue.changeType('FEATURE'); + expect(issue.type).toBe('FEATURE'); + + expect(() => issue.changeType('EPIC')).toThrow(); + }); + it('assigns and unassigns an issue', () => { const issue = build(); @@ -169,6 +183,7 @@ describe('Issue', () => { content: null, status: 'OPEN', priority: 0, + type: 'TASK', assignee: null, reporter: 'user-1', projectId: 'project-1', @@ -193,6 +208,7 @@ describe('Issue', () => { issue.updateContent('Text'); issue.changeStatus('IN_PROGRESS'); issue.changePriority(3); + issue.changeType('BUG'); issue.assignTo('user-2'); issue.updateDueDate(new Date('2026-02-01T00:00:00.000Z')); @@ -201,6 +217,7 @@ describe('Issue', () => { { field: 'content', oldValue: null, newValue: 'Text' }, { field: 'status', oldValue: 'OPEN', newValue: 'IN_PROGRESS' }, { field: 'priority', oldValue: '0', newValue: '3' }, + { field: 'type', oldValue: 'TASK', newValue: 'BUG' }, { field: 'assignee', oldValue: null, newValue: 'user-2' }, { field: 'dueDate', oldValue: null, newValue: '2026-02-01T00:00:00.000Z' }, ]); diff --git a/src/modules/issue/domain/entities/issue.entity.ts b/src/modules/issue/domain/entities/issue.entity.ts index 3ce47eb..451420e 100644 --- a/src/modules/issue/domain/entities/issue.entity.ts +++ b/src/modules/issue/domain/entities/issue.entity.ts @@ -1,6 +1,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 { IssueCreatedEvent } from '../events/issue-created.event.js'; import { IssueUpdatedEvent } from '../events/issue-updated.event.js'; import { IssueAssignedEvent } from '../events/issue-assigned.event.js'; @@ -13,6 +14,7 @@ interface IssueProps { content: string | null; status: IssueStatus; priority: IssuePriority; + type: IssueType; assignee: string | null; /** null — автор задачи удалил аккаунт */ reporter: string | null; @@ -46,6 +48,7 @@ export class Issue { title: string; content?: string | null; priority?: number; + type?: string; assignee?: string | null; reporter: string; projectId: string; @@ -58,6 +61,7 @@ export class Issue { content: params.content ?? null, status: IssueStatus.open(), priority: params.priority !== undefined ? IssuePriority.create(params.priority) : IssuePriority.default(), + type: params.type !== undefined ? IssueType.create(params.type) : IssueType.default(), assignee: params.assignee ?? null, reporter: params.reporter, projectId: params.projectId, @@ -84,6 +88,7 @@ export class Issue { content: string | null; status: string; priority: number; + type: string; assignee: string | null; reporter: string | null; projectId: string; @@ -98,6 +103,7 @@ export class Issue { content: props.content, status: IssueStatus.create(props.status), priority: IssuePriority.create(props.priority), + type: IssueType.create(props.type), assignee: props.assignee, reporter: props.reporter, projectId: props.projectId, @@ -131,6 +137,10 @@ export class Issue { return this.props.priority.value; } + get type(): string { + return this.props.type.value; + } + get assignee(): string | null { return this.props.assignee; } @@ -178,6 +188,12 @@ export class Issue { this.props.priority = next; } + changeType(type: string): void { + const next = IssueType.create(type); + this.recordChange('type', this.type, next.value); + this.props.type = next; + } + assignTo(userId: string): void { if (this.assignee === userId) return; diff --git a/src/modules/issue/domain/repositories/issue.repository.interface.ts b/src/modules/issue/domain/repositories/issue.repository.interface.ts index 8997fe4..5319d7f 100644 --- a/src/modules/issue/domain/repositories/issue.repository.interface.ts +++ b/src/modules/issue/domain/repositories/issue.repository.interface.ts @@ -17,6 +17,7 @@ export interface IIssueRepository { assigneeId?: string; projectId?: string; hasDueDate?: boolean; + type?: string[]; }): Promise<{ list: Issue[]; meta: PaginationMeta }>; /** Задачи с dueDate в [from, to), по возрастанию dueDate, не больше limit */ findByDueDateRange(params: { diff --git a/src/modules/issue/domain/value-objects/issue-type.vo.spec.ts b/src/modules/issue/domain/value-objects/issue-type.vo.spec.ts new file mode 100644 index 0000000..7b06719 --- /dev/null +++ b/src/modules/issue/domain/value-objects/issue-type.vo.spec.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { IssueType, ISSUE_TYPES } from './issue-type.vo.js'; + +describe('IssueType', () => { + it.each(ISSUE_TYPES.map((type) => type.alias))('accepts the valid type "%s"', (alias) => { + expect(IssueType.create(alias).value).toBe(alias); + }); + + it('throws for an unknown type', () => { + expect(() => IssueType.create('EPIC')).toThrow(/Недопустимый тип задачи/); + }); + + it('defaults to TASK', () => { + expect(IssueType.default().value).toBe('TASK'); + }); + + it('compares two types by value', () => { + expect(IssueType.create('BUG').equals(IssueType.create('BUG'))).toBe(true); + expect(IssueType.create('BUG').equals(IssueType.create('TASK'))).toBe(false); + }); +}); diff --git a/src/modules/issue/domain/value-objects/issue-type.vo.ts b/src/modules/issue/domain/value-objects/issue-type.vo.ts new file mode 100644 index 0000000..a24dfa2 --- /dev/null +++ b/src/modules/issue/domain/value-objects/issue-type.vo.ts @@ -0,0 +1,33 @@ +export const ISSUE_TYPES = [ + { name: 'Task', alias: 'TASK', color: 'primary' }, + { name: 'Bug', alias: 'BUG', color: 'error' }, + { name: 'Feature', alias: 'FEATURE', color: 'success' }, +] as const; + +export type IssueTypeValue = (typeof ISSUE_TYPES)[number]['alias']; + +export const ISSUE_TYPE_ALIASES: readonly IssueTypeValue[] = ISSUE_TYPES.map((type) => type.alias); + +export class IssueType { + private constructor(private readonly _value: IssueTypeValue) {} + + static create(value: string): IssueType { + if (!ISSUE_TYPE_ALIASES.includes(value as IssueTypeValue)) { + throw new Error(`Недопустимый тип задачи: "${value}". Разрешены: ${ISSUE_TYPE_ALIASES.join(', ')}`); + } + + return new IssueType(value as IssueTypeValue); + } + + static default(): IssueType { + return new IssueType('TASK'); + } + + get value(): IssueTypeValue { + return this._value; + } + + equals(other: IssueType): boolean { + return this._value === other.value; + } +} 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 2cc79c0..2904431 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 @@ -10,6 +10,7 @@ describe('IssuePrismaMapper', () => { content: 'Details', status: 'IN_PROGRESS', priority: 2, + type: 'BUG', assigneeId: 'user-2', reporterId: 'user-1', projectId: 'project-1', @@ -26,6 +27,7 @@ describe('IssuePrismaMapper', () => { expect(issue.title).toBe('Fix login bug'); expect(issue.status).toBe('IN_PROGRESS'); expect(issue.priority).toBe(2); + expect(issue.type).toBe('BUG'); expect(issue.assignee).toBe('user-2'); }); @@ -45,6 +47,7 @@ describe('IssuePrismaMapper', () => { content: null, status: 'OPEN', priority: 0, + type: 'TASK', assigneeId: null, reporterId: 'user-1', projectId: 'project-1', @@ -61,6 +64,7 @@ describe('IssuePrismaMapper', () => { title: 'Updated title', status: 'IN_PROGRESS', priority: 2, + type: 'BUG', assigneeId: 'user-2', }); expect(input).not.toHaveProperty('id'); 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 bef91be..bc552b7 100644 --- a/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts +++ b/src/modules/issue/infrastructure/persistence/prisma/mappers/issue.mapper.ts @@ -11,6 +11,7 @@ export class IssuePrismaMapper { content: record.content, status: record.status, priority: record.priority, + type: record.type, assignee: record.assigneeId, reporter: record.reporterId, projectId: record.projectId, @@ -27,6 +28,7 @@ export class IssuePrismaMapper { content: issue.content, status: issue.status, priority: issue.priority, + type: issue.type, assigneeId: issue.assignee, reporterId: issue.reporter, projectId: issue.projectId, @@ -42,6 +44,7 @@ export class IssuePrismaMapper { content: issue.content, status: issue.status, priority: issue.priority, + type: issue.type, assigneeId: issue.assignee, dueDate: issue.dueDate, updatedAt: issue.updatedAt, 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 f18f39c..2b1c98a 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 @@ -73,6 +73,7 @@ export class IssuePrismaRepository implements IIssueRepository { assigneeId?: string; projectId?: string; hasDueDate?: boolean; + type?: string[]; }): Promise<{ list: Issue[]; meta: PaginationMeta }> { const page = Math.max(1, params?.page); const limit = Math.max(1, params?.limit); @@ -81,6 +82,7 @@ export class IssuePrismaRepository implements IIssueRepository { where: { AND: [ ...commonFilters(params), + ...(params.type?.length ? [{ type: { in: params.type } }] : []), ...(params.hasDueDate !== undefined ? [{ dueDate: params.hasDueDate ? { not: null } : null }] : []), ...(params.text ? [ diff --git a/src/modules/issue/presentation/controllers/issue.controller.ts b/src/modules/issue/presentation/controllers/issue.controller.ts index 18ead69..e9ab999 100644 --- a/src/modules/issue/presentation/controllers/issue.controller.ts +++ b/src/modules/issue/presentation/controllers/issue.controller.ts @@ -70,6 +70,7 @@ export class IssueController { dto.priority, dto.assignee, dto.dueDate !== undefined ? new Date(dto.dueDate) : undefined, + dto.type, ), ), ); @@ -87,6 +88,7 @@ export class IssueController { query.text, query.assigneeId, query.hasDueDate, + query.type, ), ); } @@ -167,6 +169,7 @@ export class IssueController { dto.priority, dto.assignee, dto.dueDate === undefined ? undefined : dto.dueDate === null ? null : new Date(dto.dueDate), + dto.type, ), ), ); diff --git a/src/modules/issue/presentation/dto/calendar-issues-query.dto.ts b/src/modules/issue/presentation/dto/calendar-issues-query.dto.ts index a7eb25d..db54acc 100644 --- a/src/modules/issue/presentation/dto/calendar-issues-query.dto.ts +++ b/src/modules/issue/presentation/dto/calendar-issues-query.dto.ts @@ -1,6 +1,6 @@ import { IsISO8601, Matches } from 'class-validator'; import { StringDecorator } from '../../../../common/decorators/validators.js'; -import { IssueStatusListDecorator } from './issue-status-list.decorator.js'; +import { IssueStatusListDecorator } from './issue-alias-list.decorator.js'; /** Без смещения дата трактовалась бы в часовом поясе сервера */ const WITH_OFFSET = /(Z|[+-]\d{2}:\d{2})$/; diff --git a/src/modules/issue/presentation/dto/create-issue.dto.ts b/src/modules/issue/presentation/dto/create-issue.dto.ts index bdaf0ff..69d2f8f 100644 --- a/src/modules/issue/presentation/dto/create-issue.dto.ts +++ b/src/modules/issue/presentation/dto/create-issue.dto.ts @@ -1,4 +1,5 @@ -import { IsDateString, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; +import { IsDateString, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; +import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js'; export class CreateIssueRequestDto { @IsString() @@ -24,6 +25,10 @@ export class CreateIssueRequestDto { @Max(4) priority?: number; + @IsOptional() + @IsIn(ISSUE_TYPE_ALIASES) + type?: string; + @IsOptional() @IsString() assignee?: string; diff --git a/src/modules/issue/presentation/dto/filter-issue.dto.ts b/src/modules/issue/presentation/dto/filter-issue.dto.ts index dbd5bc3..a36e9f9 100644 --- a/src/modules/issue/presentation/dto/filter-issue.dto.ts +++ b/src/modules/issue/presentation/dto/filter-issue.dto.ts @@ -1,5 +1,5 @@ import { BooleanDecorator, IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js'; -import { IssueStatusListDecorator } from './issue-status-list.decorator.js'; +import { IssueStatusListDecorator, IssueTypeListDecorator } from './issue-alias-list.decorator.js'; export class FilterIssueDto { @IntDecorator({ isOptional: true }) @@ -17,6 +17,9 @@ export class FilterIssueDto { @IssueStatusListDecorator() status?: string[]; + @IssueTypeListDecorator() + type?: string[]; + @StringDecorator({ isOptional: true }) projectId?: string; diff --git a/src/modules/issue/presentation/dto/issue-status-list.decorator.ts b/src/modules/issue/presentation/dto/issue-alias-list.decorator.ts similarity index 65% rename from src/modules/issue/presentation/dto/issue-status-list.decorator.ts rename to src/modules/issue/presentation/dto/issue-alias-list.decorator.ts index 199a0fb..bdefcdf 100644 --- a/src/modules/issue/presentation/dto/issue-status-list.decorator.ts +++ b/src/modules/issue/presentation/dto/issue-alias-list.decorator.ts @@ -2,9 +2,10 @@ import { applyDecorators } from '@nestjs/common'; import { Transform } from 'class-transformer'; import { ArrayNotEmpty, IsArray, IsIn, IsOptional } 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'; /** Comma-separated (`?status=OPEN,IN_PROGRESS`) or repeated (`?status=OPEN&status=IN_PROGRESS`) */ -export function IssueStatusListDecorator() { +function AliasListDecorator(aliases: readonly string[]) { return applyDecorators( IsOptional(), Transform(({ value }) => @@ -18,9 +19,15 @@ export function IssueStatusListDecorator() { ), IsArray(), ArrayNotEmpty(), - IsIn( - ISSUE_STATUSES.map((status) => status.alias), - { each: true }, - ), + IsIn(aliases, { each: true }), ); } + +export function IssueStatusListDecorator() { + return AliasListDecorator(ISSUE_STATUSES.map((status) => status.alias)); +} + +/** `?type=BUG,FEATURE` или `?type=BUG&type=FEATURE` */ +export function IssueTypeListDecorator() { + return AliasListDecorator(ISSUE_TYPE_ALIASES); +} diff --git a/src/modules/issue/presentation/dto/update-issue.dto.ts b/src/modules/issue/presentation/dto/update-issue.dto.ts index dc6d1e6..b208ddb 100644 --- a/src/modules/issue/presentation/dto/update-issue.dto.ts +++ b/src/modules/issue/presentation/dto/update-issue.dto.ts @@ -1,5 +1,6 @@ import { IsDateString, IsIn, IsInt, IsOptional, IsString, 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'; export class UpdateIssueRequestDto { @IsOptional() @@ -21,6 +22,10 @@ export class UpdateIssueRequestDto { @Max(4) priority?: number; + @IsOptional() + @IsIn(ISSUE_TYPE_ALIASES) + type?: string; + @IsOptional() @IsString() assignee?: string | null; diff --git a/src/modules/issue/presentation/graphql/create-issue.input.ts b/src/modules/issue/presentation/graphql/create-issue.input.ts index 3654ca0..51329c3 100644 --- a/src/modules/issue/presentation/graphql/create-issue.input.ts +++ b/src/modules/issue/presentation/graphql/create-issue.input.ts @@ -1,5 +1,6 @@ import { Field, InputType, Int } from '@nestjs/graphql'; -import { IsDate, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; +import { IsDate, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator'; +import { ISSUE_TYPE_ALIASES } from '../../domain/value-objects/issue-type.vo.js'; @InputType() export class CreateIssueInput { @@ -31,6 +32,11 @@ export class CreateIssueInput { @Max(4) priority?: number; + @Field({ nullable: true }) + @IsOptional() + @IsIn(ISSUE_TYPE_ALIASES) + type?: string; + @Field({ nullable: true }) @IsOptional() @IsString() diff --git a/src/modules/issue/presentation/graphql/issue-detail.type.ts b/src/modules/issue/presentation/graphql/issue-detail.type.ts index aaea814..09a4448 100644 --- a/src/modules/issue/presentation/graphql/issue-detail.type.ts +++ b/src/modules/issue/presentation/graphql/issue-detail.type.ts @@ -21,6 +21,9 @@ export class IssueDetailType { @Field(() => Int) priority: number; + @Field() + type: string; + @Field(() => String, { nullable: true }) assigneeId: string | null; diff --git a/src/modules/issue/presentation/graphql/issue.type.ts b/src/modules/issue/presentation/graphql/issue.type.ts index a9fd6e2..27ec44d 100644 --- a/src/modules/issue/presentation/graphql/issue.type.ts +++ b/src/modules/issue/presentation/graphql/issue.type.ts @@ -20,6 +20,9 @@ export class IssueType { @Field(() => Int) priority: number; + @Field() + type: string; + @Field(() => String, { nullable: true }) assigneeId: string | null; diff --git a/src/modules/issue/presentation/graphql/update-issue.input.ts b/src/modules/issue/presentation/graphql/update-issue.input.ts index f3136e5..5589999 100644 --- a/src/modules/issue/presentation/graphql/update-issue.input.ts +++ b/src/modules/issue/presentation/graphql/update-issue.input.ts @@ -1,6 +1,7 @@ import { Field, InputType, Int } from '@nestjs/graphql'; import { IsDate, IsIn, IsInt, IsOptional, IsString, 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'; @InputType() export class UpdateIssueInput { @@ -27,6 +28,11 @@ export class UpdateIssueInput { @Max(4) priority?: number; + @Field({ nullable: true }) + @IsOptional() + @IsIn(ISSUE_TYPE_ALIASES) + type?: string; + @Field(() => String, { nullable: true }) @IsOptional() @IsString() diff --git a/src/modules/issue/presentation/resolvers/issue.resolver.ts b/src/modules/issue/presentation/resolvers/issue.resolver.ts index 588011a..84ef481 100644 --- a/src/modules/issue/presentation/resolvers/issue.resolver.ts +++ b/src/modules/issue/presentation/resolvers/issue.resolver.ts @@ -57,6 +57,7 @@ export class IssueResolver { @Session() session: UserSession, @Args('projectId', { type: () => String, nullable: true }) projectId?: string, @Args('hasDueDate', { type: () => Boolean, nullable: true }) hasDueDate?: boolean, + @Args('type', { type: () => [String], nullable: true }) type?: string[], ): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> { return this.listIssuesUseCase.execute( new ListIssuesQuery( @@ -68,6 +69,7 @@ export class IssueResolver { undefined, undefined, hasDueDate ?? undefined, + type ?? undefined, ), ); } @@ -102,6 +104,7 @@ export class IssueResolver { input.priority, input.assignee, input.dueDate, + input.type, ), ), ); @@ -124,6 +127,7 @@ export class IssueResolver { input.priority, input.assignee, input.dueDate, + input.type, ), ), );