import { Body, Controller, Delete, Get, HttpCode, HttpStatus, type MessageEvent, NotFoundException, Param, ParseIntPipe, Patch, Post, Query, Sse, } from '@nestjs/common'; import { Session, type UserSession } from '@thallesp/nestjs-better-auth'; import { filter, map, merge, type Observable } from 'rxjs'; import { IssueDto } from '../../application/dto/issue.dto.js'; import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js'; import { GetIssueUseCase } from '../../application/use-cases/get-issue/get-issue.use-case.js'; import { ListIssuesUseCase } from '../../application/use-cases/list-issues/list-issues.use-case.js'; import { UpdateIssueUseCase } from '../../application/use-cases/update-issue/update-issue.use-case.js'; import { DeleteIssueUseCase } from '../../application/use-cases/delete-issue/delete-issue.use-case.js'; import { CreateIssueCommand } from '../../application/use-cases/create-issue/create-issue.command.js'; import { GetIssueQuery } from '../../application/use-cases/get-issue/get-issue.query.js'; import { ListIssuesQuery } from '../../application/use-cases/list-issues/list-issues.query.js'; import { UpdateIssueCommand } from '../../application/use-cases/update-issue/update-issue.command.js'; import { DeleteIssueCommand } from '../../application/use-cases/delete-issue/delete-issue.command.js'; import { CreateIssueRequestDto } from '../dto/create-issue.dto.js'; import { UpdateIssueRequestDto } from '../dto/update-issue.dto.js'; import { IssueNotFoundException } from '../../domain/exceptions/issue-not-found.exception.js'; import { IssueProjectNotFoundException } from '../../domain/exceptions/issue-project-not-found.exception.js'; import { FilterIssueDto } from '../dto/filter-issue.dto.js'; import { GetIssueByCkeyUseCase } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.js'; import { GetIssueByCkeyQuery } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.query.js'; import { IssueEventsService } from '../../application/services/issue-events.service.js'; import { GetIssueStatsUseCase } from '../../application/use-cases/get-issue-stats/get-issue-stats.use-case.js'; @Controller('issues') export class IssueController { constructor( private readonly createIssueUseCase: CreateIssueUseCase, private readonly getIssueUseCase: GetIssueUseCase, private readonly getIssueByCkeyUseCase: GetIssueByCkeyUseCase, private readonly listIssuesUseCase: ListIssuesUseCase, private readonly updateIssueUseCase: UpdateIssueUseCase, private readonly deleteIssueUseCase: DeleteIssueUseCase, private readonly getIssueStatsUseCase: GetIssueStatsUseCase, private readonly issueEventsService: IssueEventsService, ) {} @Post() create(@Body() dto: CreateIssueRequestDto, @Session() session: UserSession) { return this.handleErrors(() => this.createIssueUseCase.execute( new CreateIssueCommand( dto.key, dto.title, session.user.id, dto.projectId, dto.content, dto.priority, dto.assignee, ), ), ); } @Get('list') list(@Query() query: FilterIssueDto, @Session() session: UserSession) { return this.listIssuesUseCase.execute( new ListIssuesQuery(Number(query.limit), Number(query.page), session.user.id, query.projectId), ); } @Get('stats') stats(@Session() session: UserSession) { return this.getIssueStatsUseCase.execute(session.user.id); } /** * SSE-поток для зарегистрированных пользователей (доступ уже проверен * глобальным AuthGuard): уведомляет о каждой созданной, обновлённой * и удалённой задаче, видимой текущему пользователю (reporter/assignee — * то же правило видимости, что и в IssuePrismaRepository.ownedByUser). */ @Sse('events') issueCreatedEvents(@Session() session: UserSession): Observable { const isVisibleToUser = (issue: IssueDto) => issue.reporterId === session.user.id || issue.assigneeId === session.user.id; return merge( this.issueEventsService.onIssueCreated().pipe( filter(isVisibleToUser), map((issue) => ({ type: 'issue.created', data: issue })), ), this.issueEventsService.onIssueUpdated().pipe( filter(isVisibleToUser), map((issue) => ({ type: 'issue.updated', data: issue })), ), this.issueEventsService.onIssueDeleted().pipe( filter(isVisibleToUser), map((issue) => ({ type: 'issue.deleted', data: issue })), ), ); } @Get(':id') async getById(@Param('id', ParseIntPipe) id: number, @Session() session: UserSession) { return this.handleErrors(() => this.getIssueUseCase.execute(new GetIssueQuery(id, session.user.id))); } @Get('by-key/:key') async getByCKey(@Param('key') ckey: string, @Session() session: UserSession) { return this.handleErrors(() => this.getIssueByCkeyUseCase.execute(new GetIssueByCkeyQuery(ckey, session.user.id))); } @Patch(':id') async update( @Param('id', ParseIntPipe) id: number, @Body() dto: UpdateIssueRequestDto, @Session() session: UserSession, ) { return this.handleErrors(() => this.updateIssueUseCase.execute( new UpdateIssueCommand(id, session.user.id, dto.title, dto.content, dto.status, dto.priority, dto.assignee), ), ); } @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async delete(@Param('id', ParseIntPipe) id: number, @Session() session: UserSession) { await this.handleErrors(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id, session.user.id))); } /** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */ private async handleErrors(fn: () => Promise): Promise { try { return await fn(); } catch (error) { if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) { throw new NotFoundException(error.message); } throw error; } } }