commit
7b999a670c
243 files changed
+24818
No files matched your search
@@ -0,0 +1,10 @@
|
||||
export class IssueStatusCountDto {
|
||||
status: string;
|
||||
label: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export class IssueStatsDto {
|
||||
total: number;
|
||||
byStatus: IssueStatusCountDto[];
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||
|
||||
export class IssueWithUsersDto {
|
||||
id: number;
|
||||
key: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
status: string;
|
||||
priority: number;
|
||||
assigneeId: string | null;
|
||||
reporterId: string;
|
||||
assignee: UserDto | null;
|
||||
reporter: UserDto;
|
||||
projectId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
export class IssueDto {
|
||||
id: number;
|
||||
key: string;
|
||||
title: string;
|
||||
content: string | null;
|
||||
status: string;
|
||||
priority: number;
|
||||
assigneeId: string | null;
|
||||
reporterId: string;
|
||||
projectId: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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';
|
||||
|
||||
export class IssueApplicationMapper {
|
||||
static toDto(issue: Issue): IssueDto {
|
||||
return {
|
||||
id: issue.id as number,
|
||||
key: issue.key,
|
||||
title: issue.title,
|
||||
content: issue.content,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
assigneeId: issue.assignee,
|
||||
reporterId: issue.reporter,
|
||||
projectId: issue.projectId,
|
||||
createdAt: issue.createdAt,
|
||||
updatedAt: issue.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Раскрывает assignee/reporter в полноценных пользователей (см. GetIssueUseCase) */
|
||||
static toDtoWithUsers(issue: Issue, usersById: ReadonlyMap<string, UserDto>): IssueWithUsersDto {
|
||||
const reporter = usersById.get(issue.reporter);
|
||||
if (!reporter) {
|
||||
throw new Error(`Reporter user ${issue.reporter} not found for issue ${issue.key}`);
|
||||
}
|
||||
|
||||
return {
|
||||
id: issue.id as number,
|
||||
key: issue.key,
|
||||
title: issue.title,
|
||||
content: issue.content,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
assigneeId: issue.assignee,
|
||||
reporterId: issue.reporter,
|
||||
assignee: issue.assignee ? (usersById.get(issue.assignee) ?? null) : null,
|
||||
reporter,
|
||||
projectId: issue.projectId,
|
||||
createdAt: issue.createdAt,
|
||||
updatedAt: issue.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Observable, Subject } from 'rxjs';
|
||||
import { IssueDto } from '../dto/issue.dto.js';
|
||||
|
||||
/**
|
||||
* In-process шина событий issue-модуля для SSE-подписчиков.
|
||||
* Живёт как синглтон-провайдер, поэтому все запросы в рамках
|
||||
* одного инстанса приложения получают одни и те же события.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IssueEventsService {
|
||||
private readonly issueCreated$ = new Subject<IssueDto>();
|
||||
private readonly issueUpdated$ = new Subject<IssueDto>();
|
||||
private readonly issueDeleted$ = new Subject<IssueDto>();
|
||||
|
||||
emitIssueCreated(issue: IssueDto): void {
|
||||
this.issueCreated$.next(issue);
|
||||
}
|
||||
|
||||
onIssueCreated(): Observable<IssueDto> {
|
||||
return this.issueCreated$.asObservable();
|
||||
}
|
||||
|
||||
emitIssueUpdated(issue: IssueDto): void {
|
||||
this.issueUpdated$.next(issue);
|
||||
}
|
||||
|
||||
onIssueUpdated(): Observable<IssueDto> {
|
||||
return this.issueUpdated$.asObservable();
|
||||
}
|
||||
|
||||
emitIssueDeleted(issue: IssueDto): void {
|
||||
this.issueDeleted$.next(issue);
|
||||
}
|
||||
|
||||
onIssueDeleted(): Observable<IssueDto> {
|
||||
return this.issueDeleted$.asObservable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Issue } from '../../domain/entities/issue.entity.js';
|
||||
import { IssueUserExpanderService } from './issue-user-expander.service.js';
|
||||
import type { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import type { UserDto } from '../../../user/application/dto/user.dto.js';
|
||||
|
||||
const FIXED_DATE = new Date('2026-01-01T00:00:00.000Z');
|
||||
|
||||
const buildUser = (id: string): UserDto => ({
|
||||
id,
|
||||
name: id,
|
||||
email: `${id}@example.com`,
|
||||
emailVerified: true,
|
||||
image: null,
|
||||
createdAt: FIXED_DATE,
|
||||
updatedAt: FIXED_DATE,
|
||||
});
|
||||
|
||||
const buildIssue = (overrides: Partial<{ assignee: string | null; reporter: string }> = {}) =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Fix login bug',
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
assignee: 'assignee' in overrides ? (overrides.assignee ?? null) : 'user-2',
|
||||
reporter: overrides.reporter ?? 'user-1',
|
||||
projectId: 'project-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('IssueUserExpanderService', () => {
|
||||
let getUsersUseCase: GetUsersUseCase;
|
||||
let service: IssueUserExpanderService;
|
||||
|
||||
beforeEach(() => {
|
||||
getUsersUseCase = { execute: vi.fn() } as unknown as GetUsersUseCase;
|
||||
service = new IssueUserExpanderService(getUsersUseCase);
|
||||
});
|
||||
|
||||
it('resolves assignee and reporter to full users', async () => {
|
||||
vi.mocked(getUsersUseCase.execute).mockResolvedValue([buildUser('user-1'), buildUser('user-2')]);
|
||||
|
||||
const dto = await service.expand(buildIssue());
|
||||
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-1', 'user-2'] }));
|
||||
expect(dto.reporterId).toBe('user-1');
|
||||
expect(dto.assigneeId).toBe('user-2');
|
||||
expect(dto.reporter).toEqual(buildUser('user-1'));
|
||||
expect(dto.assignee).toEqual(buildUser('user-2'));
|
||||
});
|
||||
|
||||
it('resolves assignee to null when the issue has no assignee', async () => {
|
||||
vi.mocked(getUsersUseCase.execute).mockResolvedValue([buildUser('user-1')]);
|
||||
|
||||
const dto = await service.expand(buildIssue({ assignee: null }));
|
||||
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-1'] }));
|
||||
expect(dto.assignee).toBeNull();
|
||||
});
|
||||
|
||||
it('requests each user id only once when reporter and assignee are the same person', async () => {
|
||||
vi.mocked(getUsersUseCase.execute).mockResolvedValue([buildUser('user-1')]);
|
||||
|
||||
await service.expand(buildIssue({ assignee: 'user-1', reporter: 'user-1' }));
|
||||
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-1'] }));
|
||||
});
|
||||
|
||||
describe('expandAll', () => {
|
||||
it('resolves assignee/reporter for every issue in a single batched lookup', async () => {
|
||||
vi.mocked(getUsersUseCase.execute).mockResolvedValue([buildUser('user-1'), buildUser('user-2')]);
|
||||
|
||||
const [first, second] = await service.expandAll([
|
||||
buildIssue({ assignee: 'user-2', reporter: 'user-1' }),
|
||||
buildIssue({ assignee: null, reporter: 'user-1' }),
|
||||
]);
|
||||
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledTimes(1);
|
||||
expect(getUsersUseCase.execute).toHaveBeenCalledWith(expect.objectContaining({ ids: ['user-1', 'user-2'] }));
|
||||
expect(first!.assignee).toEqual(buildUser('user-2'));
|
||||
expect(second!.assignee).toBeNull();
|
||||
});
|
||||
|
||||
it('returns an empty array without calling GetUsersUseCase when there are no issues', async () => {
|
||||
const result = await service.expandAll([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(getUsersUseCase.execute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Issue } from '../../domain/entities/issue.entity.js';
|
||||
import { IssueWithUsersDto } from '../dto/issue-with-users.dto.js';
|
||||
import { IssueApplicationMapper } from '../mappers/issue-application.mapper.js';
|
||||
import { GetUsersUseCase } from '../../../user/application/use-cases/get-users/get-users.use-case.js';
|
||||
import { GetUsersQuery } from '../../../user/application/use-cases/get-users/get-users.query.js';
|
||||
|
||||
/** Раскрывает assignee/reporter задачи (или списка задач) в полноценных пользователей */
|
||||
@Injectable()
|
||||
export class IssueUserExpanderService {
|
||||
constructor(private readonly getUsersUseCase: GetUsersUseCase) {}
|
||||
|
||||
async expand(issue: Issue): Promise<IssueWithUsersDto> {
|
||||
const [dto] = await this.expandAll([issue]);
|
||||
return dto!;
|
||||
}
|
||||
|
||||
async expandAll(issues: Issue[]): Promise<IssueWithUsersDto[]> {
|
||||
if (issues.length === 0) return [];
|
||||
|
||||
const userIds = [
|
||||
...new Set(issues.flatMap((issue) => [issue.reporter, issue.assignee]).filter((id): id is string => Boolean(id))),
|
||||
];
|
||||
const users = await this.getUsersUseCase.execute(new GetUsersQuery(userIds));
|
||||
const usersById = new Map(users.map((user) => [user.id, user]));
|
||||
|
||||
return issues.map((issue) => IssueApplicationMapper.toDtoWithUsers(issue, usersById));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class CreateIssueCommand {
|
||||
constructor(
|
||||
public readonly key: string,
|
||||
public readonly title: string,
|
||||
public readonly reporter: string,
|
||||
public readonly projectId: string,
|
||||
public readonly content?: string,
|
||||
public readonly priority?: number,
|
||||
public readonly assignee?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.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';
|
||||
import { CreateIssueCommand } from './create-issue.command.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||
|
||||
describe('CreateIssueUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
let projectRepository: IProjectRepository;
|
||||
let issueEventsService: IssueEventsService;
|
||||
let issueUserExpander: IssueUserExpanderService;
|
||||
let useCase: CreateIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: vi.fn(),
|
||||
findByCKey: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
countByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
projectRepository = {
|
||||
findById: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
addMember: vi.fn(),
|
||||
};
|
||||
issueEventsService = new IssueEventsService();
|
||||
issueUserExpander = {
|
||||
expand: vi.fn(async (issue: Issue) => ({
|
||||
...IssueApplicationMapper.toDto(issue),
|
||||
assignee: null,
|
||||
reporter: null,
|
||||
})),
|
||||
} as unknown as IssueUserExpanderService;
|
||||
useCase = new CreateIssueUseCase(repository, projectRepository, issueEventsService, issueUserExpander);
|
||||
});
|
||||
|
||||
it('persists a new issue and returns it as a DTO with the DB-assigned id', async () => {
|
||||
vi.mocked(repository.create).mockImplementation(async (issue) =>
|
||||
Issue.reconstitute({
|
||||
id: 7,
|
||||
key: issue.key,
|
||||
title: issue.title,
|
||||
content: issue.content,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
assignee: issue.assignee,
|
||||
reporter: issue.reporter,
|
||||
projectId: issue.projectId,
|
||||
createdAt: issue.createdAt,
|
||||
updatedAt: issue.updatedAt,
|
||||
}),
|
||||
);
|
||||
|
||||
const dto = await useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-1', 'project-1'));
|
||||
|
||||
expect(repository.create).toHaveBeenCalledTimes(1);
|
||||
expect(dto).toMatchObject({
|
||||
id: 7,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Fix login bug',
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
reporterId: 'user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the optional fields through to the domain entity', async () => {
|
||||
vi.mocked(repository.create).mockImplementation(async (issue) => issue);
|
||||
|
||||
const dto = await useCase.execute(
|
||||
new CreateIssueCommand('ISSUE-2', 'Improve onboarding', 'user-1', 'project-1', 'Some content', 3, 'user-2'),
|
||||
);
|
||||
|
||||
expect(dto.content).toBe('Some content');
|
||||
expect(dto.priority).toBe(3);
|
||||
expect(dto.assigneeId).toBe('user-2');
|
||||
});
|
||||
|
||||
it('rejects an invalid title before touching the repository', async () => {
|
||||
await expect(useCase.execute(new CreateIssueCommand('ISSUE-3', '', 'user-1', 'project-1'))).rejects.toThrow();
|
||||
|
||||
expect(repository.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits an issue-created event for SSE subscribers', async () => {
|
||||
vi.mocked(repository.create).mockImplementation(async (issue) =>
|
||||
Issue.reconstitute({
|
||||
id: 7,
|
||||
key: issue.key,
|
||||
title: issue.title,
|
||||
content: issue.content,
|
||||
status: issue.status,
|
||||
priority: issue.priority,
|
||||
assignee: issue.assignee,
|
||||
reporter: issue.reporter,
|
||||
projectId: issue.projectId,
|
||||
createdAt: issue.createdAt,
|
||||
updatedAt: issue.updatedAt,
|
||||
}),
|
||||
);
|
||||
|
||||
const received: unknown[] = [];
|
||||
issueEventsService.onIssueCreated().subscribe((issue) => received.push(issue));
|
||||
|
||||
const dto = await useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-1', 'project-1'));
|
||||
|
||||
expect(received).toEqual([dto]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
} from '../../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { CreateIssueCommand } from './create-issue.command.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 { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
|
||||
|
||||
@Injectable()
|
||||
export class CreateIssueUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepository: IProjectRepository,
|
||||
private readonly issueEventsService: IssueEventsService,
|
||||
private readonly issueUserExpander: IssueUserExpanderService,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateIssueCommand): Promise<IssueWithUsersDto> {
|
||||
const draft = Issue.create({
|
||||
key: command.key,
|
||||
title: command.title,
|
||||
content: command.content,
|
||||
priority: command.priority,
|
||||
assignee: command.assignee,
|
||||
reporter: command.reporter,
|
||||
projectId: command.projectId,
|
||||
});
|
||||
|
||||
const issue = await this.issueRepository.create(draft);
|
||||
const dto = await this.issueUserExpander.expand(issue);
|
||||
|
||||
if (issue.assignee) {
|
||||
await this.projectRepository.addMember(issue.projectId, issue.assignee);
|
||||
}
|
||||
|
||||
const domainEvents = draft.pullDomainEvents();
|
||||
if (domainEvents.some((event) => event instanceof IssueCreatedEvent)) {
|
||||
this.issueEventsService.emitIssueCreated(dto);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class DeleteIssueCommand {
|
||||
constructor(
|
||||
public readonly id: number,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { DeleteIssueUseCase } from './delete-issue.use-case.js';
|
||||
import { DeleteIssueCommand } from './delete-issue.command.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
|
||||
const buildIssue = () =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Title',
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('DeleteIssueUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
let issueEventsService: IssueEventsService;
|
||||
let useCase: DeleteIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: vi.fn(),
|
||||
findByCKey: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
countByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
issueEventsService = new IssueEventsService();
|
||||
useCase = new DeleteIssueUseCase(repository, issueEventsService);
|
||||
});
|
||||
|
||||
it('deletes an existing issue', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
await useCase.execute(new DeleteIssueCommand(1, 'user-1'));
|
||||
|
||||
expect(repository.delete).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('throws IssueNotFoundException and does not call delete when missing', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new DeleteIssueCommand(999, 'user-1'))).rejects.toThrow(IssueNotFoundException);
|
||||
expect(repository.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits an issue-deleted event for SSE subscribers', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
const received: unknown[] = [];
|
||||
issueEventsService.onIssueDeleted().subscribe((issue) => received.push(issue));
|
||||
|
||||
await useCase.execute(new DeleteIssueCommand(1, 'user-1'));
|
||||
|
||||
expect(received).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { DeleteIssueCommand } from './delete-issue.command.js';
|
||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class DeleteIssueUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly issueEventsService: IssueEventsService,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteIssueCommand): Promise<void> {
|
||||
const issue = await this.issueRepository.findById(command.id, command.userId);
|
||||
|
||||
if (!issue) {
|
||||
throw new IssueNotFoundException(command.id);
|
||||
}
|
||||
|
||||
await this.issueRepository.delete(command.id);
|
||||
|
||||
this.issueEventsService.emitIssueDeleted(IssueApplicationMapper.toDto(issue));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetIssueByCkeyQuery {
|
||||
constructor(
|
||||
public readonly ckey: string,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { GetIssueByCkeyUseCase } from './get-issue-by-ckey.use-case.js';
|
||||
import { GetIssueByCkeyQuery } from './get-issue-by-ckey.query.js';
|
||||
import type { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
import type { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||
|
||||
const buildIssue = () =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Fix login bug',
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
assignee: 'user-2',
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('GetIssueByCkeyUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
let issueUserExpander: IssueUserExpanderService;
|
||||
let useCase: GetIssueByCkeyUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: vi.fn(),
|
||||
findByCKey: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
countByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
issueUserExpander = { expand: vi.fn() } as unknown as IssueUserExpanderService;
|
||||
useCase = new GetIssueByCkeyUseCase(repository, issueUserExpander);
|
||||
});
|
||||
|
||||
it('returns the issue with assignee/reporter expanded by IssueUserExpanderService', async () => {
|
||||
const issue = buildIssue();
|
||||
const expanded = { id: 1, key: 'ISSUE-1' } as IssueWithUsersDto;
|
||||
vi.mocked(repository.findByCKey).mockResolvedValue(issue);
|
||||
vi.mocked(issueUserExpander.expand).mockResolvedValue(expanded);
|
||||
|
||||
const dto = await useCase.execute(new GetIssueByCkeyQuery('ISSUE-1', 'user-1'));
|
||||
|
||||
expect(repository.findByCKey).toHaveBeenCalledWith('ISSUE-1', 'user-1');
|
||||
expect(issueUserExpander.expand).toHaveBeenCalledWith(issue);
|
||||
expect(dto).toBe(expanded);
|
||||
});
|
||||
|
||||
it('throws IssueNotFoundException when the issue does not exist', async () => {
|
||||
vi.mocked(repository.findByCKey).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new GetIssueByCkeyQuery('MISSING-999', 'user-1'))).rejects.toThrow(
|
||||
IssueNotFoundException,
|
||||
);
|
||||
expect(issueUserExpander.expand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { type IIssueRepository, ISSUE_REPOSITORY } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { GetIssueByCkeyQuery } from './get-issue-by-ckey.query.js';
|
||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class GetIssueByCkeyUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly issueUserExpander: IssueUserExpanderService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetIssueByCkeyQuery): Promise<IssueWithUsersDto> {
|
||||
const issue = await this.issueRepository.findByCKey(query.ckey, query.userId);
|
||||
|
||||
if (!issue) {
|
||||
throw new IssueNotFoundException(query.ckey);
|
||||
}
|
||||
|
||||
return this.issueUserExpander.expand(issue);
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { GetIssueStatsUseCase } from './get-issue-stats.use-case.js';
|
||||
|
||||
describe('GetIssueStatsUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
let useCase: GetIssueStatsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: vi.fn(),
|
||||
findByCKey: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
countByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
useCase = new GetIssueStatsUseCase(repository);
|
||||
});
|
||||
|
||||
it('returns counts for every known status, including those with zero issues', async () => {
|
||||
vi.mocked(repository.countByStatus).mockResolvedValue([
|
||||
{ status: 'OPEN', count: 5 },
|
||||
{ status: 'DONE', count: 2 },
|
||||
]);
|
||||
|
||||
const stats = await useCase.execute('user-1');
|
||||
|
||||
expect(stats.total).toBe(7);
|
||||
expect(stats.byStatus).toEqual([
|
||||
{ status: 'OPEN', label: 'Open', count: 5 },
|
||||
{ status: 'IN_PROGRESS', label: 'In progress', count: 0 },
|
||||
{ status: 'DONE', label: 'Done', count: 2 },
|
||||
{ status: 'CANCELLED', label: 'Cancelled', count: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns all-zero stats when there are no issues', async () => {
|
||||
vi.mocked(repository.countByStatus).mockResolvedValue([]);
|
||||
|
||||
const stats = await useCase.execute('user-1');
|
||||
|
||||
expect(stats.total).toBe(0);
|
||||
expect(stats.byStatus.every((entry) => entry.count === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { ISSUE_STATUSES } from '../../../domain/value-objects/issue-status.vo.js';
|
||||
import { IssueStatsDto } from '../../dto/issue-stats.dto.js';
|
||||
|
||||
@Injectable()
|
||||
export class GetIssueStatsUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
) {}
|
||||
|
||||
async execute(userId: string): Promise<IssueStatsDto> {
|
||||
const counts = await this.issueRepository.countByStatus(userId);
|
||||
const countByStatus = new Map(counts.map((entry) => [entry.status, entry.count]));
|
||||
|
||||
// Каждый известный статус попадает в ответ, даже если по нему 0 задач
|
||||
const byStatus = ISSUE_STATUSES.map((status) => ({
|
||||
status: status.alias,
|
||||
label: status.name,
|
||||
count: countByStatus.get(status.alias) ?? 0,
|
||||
}));
|
||||
|
||||
const total = byStatus.reduce((sum, entry) => sum + entry.count, 0);
|
||||
|
||||
return { total, byStatus };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class GetIssueQuery {
|
||||
constructor(
|
||||
public readonly id: number,
|
||||
public readonly userId: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { GetIssueUseCase } from './get-issue.use-case.js';
|
||||
import { GetIssueQuery } from './get-issue.query.js';
|
||||
import type { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
import type { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||
|
||||
const buildIssue = () =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Fix login bug',
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
assignee: 'user-2',
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('GetIssueUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
let issueUserExpander: IssueUserExpanderService;
|
||||
let useCase: GetIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: vi.fn(),
|
||||
findByCKey: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
countByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
issueUserExpander = { expand: vi.fn() } as unknown as IssueUserExpanderService;
|
||||
useCase = new GetIssueUseCase(repository, issueUserExpander);
|
||||
});
|
||||
|
||||
it('returns the issue with assignee/reporter expanded by IssueUserExpanderService', async () => {
|
||||
const issue = buildIssue();
|
||||
const expanded = { id: 1, key: 'ISSUE-1' } as IssueWithUsersDto;
|
||||
vi.mocked(repository.findById).mockResolvedValue(issue);
|
||||
vi.mocked(issueUserExpander.expand).mockResolvedValue(expanded);
|
||||
|
||||
const dto = await useCase.execute(new GetIssueQuery(1, 'user-1'));
|
||||
|
||||
expect(repository.findById).toHaveBeenCalledWith(1, 'user-1');
|
||||
expect(issueUserExpander.expand).toHaveBeenCalledWith(issue);
|
||||
expect(dto).toBe(expanded);
|
||||
});
|
||||
|
||||
it('throws IssueNotFoundException when the issue does not exist', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new GetIssueQuery(999, 'user-1'))).rejects.toThrow(IssueNotFoundException);
|
||||
expect(issueUserExpander.expand).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { GetIssueQuery } from './get-issue.query.js';
|
||||
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
|
||||
@Injectable()
|
||||
export class GetIssueUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly issueUserExpander: IssueUserExpanderService,
|
||||
) {}
|
||||
|
||||
async execute(query: GetIssueQuery): Promise<IssueWithUsersDto> {
|
||||
const issue = await this.issueRepository.findById(query.id, query.userId);
|
||||
|
||||
if (!issue) {
|
||||
throw new IssueNotFoundException(query.id);
|
||||
}
|
||||
|
||||
return this.issueUserExpander.expand(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export class ListIssuesQuery {
|
||||
constructor(
|
||||
public readonly limit: number,
|
||||
public readonly page: number,
|
||||
public readonly userId: string,
|
||||
public readonly projectId?: string,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { Issue } from '../../../domain/entities/issue.entity.js';
|
||||
import { ListIssuesUseCase } from './list-issues.use-case.js';
|
||||
import { ListIssuesQuery } from './list-issues.query.js';
|
||||
import type { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
import type { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||
|
||||
const buildIssue = (id: number) =>
|
||||
Issue.reconstitute({
|
||||
id,
|
||||
key: `ISSUE-${id}`,
|
||||
title: `Issue ${id}`,
|
||||
content: null,
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
describe('ListIssuesUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
let issueUserExpander: IssueUserExpanderService;
|
||||
let useCase: ListIssuesUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: vi.fn(),
|
||||
findByCKey: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
countByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
issueUserExpander = { expandAll: vi.fn() } as unknown as IssueUserExpanderService;
|
||||
useCase = new ListIssuesUseCase(repository, issueUserExpander);
|
||||
});
|
||||
|
||||
it('forwards pagination params and expands assignee/reporter via IssueUserExpanderService', async () => {
|
||||
const issues = [buildIssue(1), buildIssue(2)];
|
||||
const expanded = [
|
||||
{ id: 1, key: 'ISSUE-1' },
|
||||
{ id: 2, key: 'ISSUE-2' },
|
||||
] as IssueWithUsersDto[];
|
||||
vi.mocked(repository.findAll).mockResolvedValue({
|
||||
list: issues,
|
||||
meta: { totalCount: 2, page: 1, totalPages: 1 },
|
||||
});
|
||||
vi.mocked(issueUserExpander.expandAll).mockResolvedValue(expanded);
|
||||
|
||||
const result = await useCase.execute(new ListIssuesQuery(20, 1, 'user-1'));
|
||||
|
||||
expect(repository.findAll).toHaveBeenCalledWith({ limit: 20, page: 1, userId: 'user-1' });
|
||||
expect(issueUserExpander.expandAll).toHaveBeenCalledWith(issues);
|
||||
expect(result.list).toBe(expanded);
|
||||
expect(result.meta).toEqual({ totalCount: 2, page: 1, totalPages: 1 });
|
||||
});
|
||||
|
||||
it('returns an empty array when there are no issues', async () => {
|
||||
vi.mocked(repository.findAll).mockResolvedValue({
|
||||
list: [],
|
||||
meta: { totalCount: 0, page: 1, totalPages: 0 },
|
||||
});
|
||||
vi.mocked(issueUserExpander.expandAll).mockResolvedValue([]);
|
||||
|
||||
const result = await useCase.execute(new ListIssuesQuery(20, 1, 'user-1'));
|
||||
|
||||
expect(result.list).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import { ListIssuesQuery } from './list-issues.query.js';
|
||||
import { IssueWithUsersDto } from '../../dto/issue-with-users.dto.js';
|
||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
import { PaginationMeta } from '../../../../../common/interfaces/pagination-meta.interface.js';
|
||||
|
||||
@Injectable()
|
||||
export class ListIssuesUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
private readonly issueUserExpander: IssueUserExpanderService,
|
||||
) {}
|
||||
|
||||
async execute(query: ListIssuesQuery): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> {
|
||||
const { list, meta } = await this.issueRepository.findAll({
|
||||
limit: query.limit,
|
||||
page: query.page,
|
||||
userId: query.userId,
|
||||
projectId: query.projectId,
|
||||
});
|
||||
|
||||
return { list: await this.issueUserExpander.expandAll(list), meta };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export class UpdateIssueCommand {
|
||||
constructor(
|
||||
public readonly id: number,
|
||||
public readonly userId: string,
|
||||
public readonly title?: string,
|
||||
public readonly content?: string,
|
||||
public readonly status?: string,
|
||||
public readonly priority?: number,
|
||||
public readonly assignee?: string | null,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { IIssueRepository } from '../../../domain/repositories/issue.repository.interface.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';
|
||||
import { UpdateIssueUseCase } from './update-issue.use-case.js';
|
||||
import { UpdateIssueCommand } from './update-issue.command.js';
|
||||
import { IssueEventsService } from '../../services/issue-events.service.js';
|
||||
import { IssueUserExpanderService } from '../../services/issue-user-expander.service.js';
|
||||
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
|
||||
|
||||
const buildIssue = () =>
|
||||
Issue.reconstitute({
|
||||
id: 1,
|
||||
key: 'ISSUE-1',
|
||||
title: 'Original title',
|
||||
content: 'Original content',
|
||||
status: 'OPEN',
|
||||
priority: 0,
|
||||
assignee: null,
|
||||
reporter: 'user-1',
|
||||
projectId: 'project-1',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
describe('UpdateIssueUseCase', () => {
|
||||
let repository: IIssueRepository;
|
||||
let projectRepository: IProjectRepository;
|
||||
let issueEventsService: IssueEventsService;
|
||||
let issueUserExpander: IssueUserExpanderService;
|
||||
let useCase: UpdateIssueUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
findById: vi.fn(),
|
||||
findByCKey: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
countByStatus: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
projectRepository = {
|
||||
findById: vi.fn(),
|
||||
findAll: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
addMember: vi.fn(),
|
||||
};
|
||||
issueEventsService = new IssueEventsService();
|
||||
issueUserExpander = {
|
||||
expand: vi.fn(async (issue: Issue) => ({
|
||||
...IssueApplicationMapper.toDto(issue),
|
||||
assignee: null,
|
||||
reporter: null,
|
||||
})),
|
||||
} as unknown as IssueUserExpanderService;
|
||||
useCase = new UpdateIssueUseCase(repository, projectRepository, issueEventsService, issueUserExpander);
|
||||
});
|
||||
|
||||
it('applies only the fields provided in the command', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
const dto = await useCase.execute(new UpdateIssueCommand(1, 'user-1', 'New title', undefined, 'IN_PROGRESS'));
|
||||
|
||||
expect(dto.title).toBe('New title');
|
||||
expect(dto.content).toBe('Original content');
|
||||
expect(dto.status).toBe('IN_PROGRESS');
|
||||
expect(repository.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('assigns and unassigns depending on the assignee value', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
const assigned = await useCase.execute(
|
||||
new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, 'user-9'),
|
||||
);
|
||||
expect(assigned.assigneeId).toBe('user-9');
|
||||
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
const unassigned = await useCase.execute(
|
||||
new UpdateIssueCommand(1, 'user-1', undefined, undefined, undefined, undefined, null),
|
||||
);
|
||||
expect(unassigned.assigneeId).toBeNull();
|
||||
});
|
||||
|
||||
it('throws IssueNotFoundException when the issue does not exist', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(null);
|
||||
|
||||
await expect(useCase.execute(new UpdateIssueCommand(999, 'user-1', 'New title'))).rejects.toThrow(
|
||||
IssueNotFoundException,
|
||||
);
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emits an issue-updated event for SSE subscribers', async () => {
|
||||
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
|
||||
|
||||
const received: unknown[] = [];
|
||||
issueEventsService.onIssueUpdated().subscribe((issue) => received.push(issue));
|
||||
|
||||
const dto = await useCase.execute(new UpdateIssueCommand(1, 'user-1', 'New title'));
|
||||
|
||||
expect(received).toEqual([dto]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ISSUE_REPOSITORY, type IIssueRepository } from '../../../domain/repositories/issue.repository.interface.js';
|
||||
import {
|
||||
PROJECT_REPOSITORY,
|
||||
type IProjectRepository,
|
||||
} from '../../../../project/domain/repositories/project.repository.interface.js';
|
||||
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { UpdateIssueCommand } from './update-issue.command.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 { IssueUpdatedEvent } from '../../../domain/events/issue-updated.event.js';
|
||||
|
||||
@Injectable()
|
||||
export class UpdateIssueUseCase {
|
||||
constructor(
|
||||
@Inject(ISSUE_REPOSITORY)
|
||||
private readonly issueRepository: IIssueRepository,
|
||||
@Inject(PROJECT_REPOSITORY)
|
||||
private readonly projectRepository: IProjectRepository,
|
||||
private readonly issueEventsService: IssueEventsService,
|
||||
private readonly issueUserExpander: IssueUserExpanderService,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateIssueCommand): Promise<IssueWithUsersDto> {
|
||||
const issue = await this.issueRepository.findById(command.id, command.userId);
|
||||
|
||||
if (!issue) {
|
||||
throw new IssueNotFoundException(command.id);
|
||||
}
|
||||
|
||||
if (command.title !== undefined) {
|
||||
issue.updateTitle(command.title);
|
||||
}
|
||||
|
||||
if (command.content !== undefined) {
|
||||
issue.updateContent(command.content);
|
||||
}
|
||||
|
||||
if (command.status !== undefined) {
|
||||
issue.changeStatus(command.status);
|
||||
}
|
||||
|
||||
if (command.priority !== undefined) {
|
||||
issue.changePriority(command.priority);
|
||||
}
|
||||
|
||||
if (command.assignee !== undefined) {
|
||||
if (command.assignee === null || command.assignee === '') {
|
||||
issue.unassign();
|
||||
} else {
|
||||
issue.assignTo(command.assignee);
|
||||
}
|
||||
}
|
||||
|
||||
await this.issueRepository.update(issue);
|
||||
const dto = await this.issueUserExpander.expand(issue);
|
||||
|
||||
if (issue.assignee) {
|
||||
await this.projectRepository.addMember(issue.projectId, issue.assignee);
|
||||
}
|
||||
|
||||
const domainEvents = issue.pullDomainEvents();
|
||||
if (domainEvents.some((event) => event instanceof IssueUpdatedEvent)) {
|
||||
this.issueEventsService.emitIssueUpdated(dto);
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user