1 parent
afc37f8fdb
commit
c5691da2b8
47 files changed
+278
-9
No files matched your search
@@ -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");
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const buildIssue = () =>
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
|
||||
+1
@@ -19,6 +19,7 @@ const buildIssue = () =>
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
|
||||
+9
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -5,6 +5,7 @@ export class IssueDto {
|
||||
content: string | null;
|
||||
status: string;
|
||||
priority: number;
|
||||
type: string;
|
||||
assigneeId: string | null;
|
||||
reporterId: string | null;
|
||||
projectId: string;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -8,5 +8,6 @@ export class CreateIssueCommand {
|
||||
public readonly priority?: number,
|
||||
public readonly assignee?: string,
|
||||
public readonly dueDate?: Date,
|
||||
public readonly type?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -14,6 +14,7 @@ const buildIssue = () =>
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
|
||||
+1
@@ -15,6 +15,7 @@ const buildIssue = () =>
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: 'user-2',
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
|
||||
@@ -15,6 +15,7 @@ const buildIssue = () =>
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: 'user-2',
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
|
||||
+1
@@ -18,6 +18,7 @@ const buildIssue = (id: number) =>
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
|
||||
@@ -8,5 +8,6 @@ export class ListIssuesQuery {
|
||||
public readonly text?: string,
|
||||
public readonly assigneeId?: string,
|
||||
public readonly hasDueDate?: boolean,
|
||||
public readonly type?: string[],
|
||||
) {}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const buildIssue = (id: number) =>
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
type: 'TASK',
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
) {}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -8,6 +8,7 @@ export const ISSUE_CHANGE_FIELDS = [
|
||||
'content',
|
||||
'status',
|
||||
'priority',
|
||||
'type',
|
||||
'assignee',
|
||||
'dueDate',
|
||||
] as const;
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
@@ -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
|
||||
? [
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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})$/;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
+12
-5
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -21,6 +21,9 @@ export class IssueDetailType {
|
||||
@Field(() => Int)
|
||||
priority: number;
|
||||
|
||||
@Field()
|
||||
type: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
assigneeId: string | null;
|
||||
|
||||
|
||||
@@ -20,6 +20,9 @@ export class IssueType {
|
||||
@Field(() => Int)
|
||||
priority: number;
|
||||
|
||||
@Field()
|
||||
type: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
assigneeId: string | null;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user