issue, user, status

This commit is contained in:
2026-09-18 14:32:38 +03:00
parent 33490da091
commit 7a4220aba8
123 changed files with 3950 additions and 712 deletions
@@ -0,0 +1,118 @@
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 { map, type Observable } from 'rxjs';
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 { 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.createIssueUseCase.execute(
new CreateIssueCommand(dto.key, dto.title, session.user.id, dto.content, dto.priority, dto.assignee),
);
}
@Get('list')
list(@Query() query: FilterIssueDto) {
return this.listIssuesUseCase.execute(new ListIssuesQuery(Number(query.limit), Number(query.page)));
}
@Get('stats')
stats() {
return this.getIssueStatsUseCase.execute();
}
/**
* SSE-поток для зарегистрированных пользователей (доступ уже проверен
* глобальным AuthGuard): уведомляет о каждой новой созданной задаче.
*/
@Sse('events')
issueCreatedEvents(@Session() _session: UserSession): Observable<MessageEvent> {
return this.issueEventsService.onIssueCreated().pipe(
map((issue) => ({
type: 'issue.created',
data: issue,
})),
);
}
@Get(':id')
async getById(@Param('id', ParseIntPipe) id: number) {
return this.handleNotFound(() => this.getIssueUseCase.execute(new GetIssueQuery(id)));
}
@Get('by-key/:key')
async getByCKey(@Param('key') ckey: string) {
return this.handleNotFound(() => this.getIssueByCkeyUseCase.execute(new GetIssueByCkeyQuery(ckey)));
}
@Patch(':id')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateIssueRequestDto) {
return this.handleNotFound(() =>
this.updateIssueUseCase.execute(
new UpdateIssueCommand(id, dto.title, dto.content, dto.status, dto.priority, dto.assignee),
),
);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id', ParseIntPipe) id: number) {
await this.handleNotFound(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id)));
}
/** Транслирует доменное исключение в HTTP-ответ — это забота presentation-слоя */
private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof IssueNotFoundException) {
throw new NotFoundException(error.message);
}
throw error;
}
}
}
@@ -0,0 +1,26 @@
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreateIssueRequestDto {
@IsString()
@IsNotEmpty()
key: string;
@IsString()
@IsNotEmpty()
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(4)
priority?: number;
@IsOptional()
@IsString()
assignee?: string;
}
@@ -0,0 +1,12 @@
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
export class FilterIssueDto {
@IntDecorator({ isOptional: true })
limit: number = 10;
@IntDecorator({ isOptional: true })
page: number = 1;
@StringDecorator({ isOptional: true })
name?: string;
}
@@ -0,0 +1,27 @@
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
export class UpdateIssueRequestDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsIn(ISSUE_STATUSES.map((status) => status.alias))
status?: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(4)
priority?: number;
@IsOptional()
@IsString()
assignee?: string | null;
}
@@ -0,0 +1,33 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
@InputType()
export class CreateIssueInput {
@Field()
@IsString()
@IsNotEmpty()
key: string;
@Field()
@IsString()
@IsNotEmpty()
@MaxLength(200)
title: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
content?: string;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
@Max(4)
priority?: number;
@Field({ nullable: true })
@IsOptional()
@IsString()
assignee?: string;
}
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IssueType } from './issue.type.js';
import { PaginationMetaType } from '../../../../common/graphql/pagination-meta.type.js';
@ObjectType('IssuePage')
export class IssuePageType {
@Field(() => [IssueType])
list: IssueType[];
@Field(() => PaginationMetaType)
meta: PaginationMetaType;
}
@@ -0,0 +1,34 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType('Issue')
export class IssueType {
@Field(() => Int)
id: number;
@Field()
key: string;
@Field()
title: string;
@Field(() => String, { nullable: true })
content: string | null;
@Field()
status: string;
@Field(() => Int)
priority: number;
@Field(() => String, { nullable: true })
assignee: string | null;
@Field()
reporter: string;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
}
@@ -0,0 +1,34 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
@InputType()
export class UpdateIssueInput {
@Field({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
content?: string;
@Field({ nullable: true })
@IsOptional()
@IsIn(ISSUE_STATUSES.map((status) => status.alias))
status?: string;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
@Max(4)
priority?: number;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
assignee?: string | null;
}
@@ -0,0 +1,86 @@
import { NotFoundException } from '@nestjs/common';
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
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 { GetIssueByCkeyUseCase } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.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 { GetIssueByCkeyQuery } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.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 { IssueDto } from '../../application/dto/issue.dto.js';
import { IssueNotFoundException } from '../../domain/exceptions/issue-not-found.exception.js';
import { IssueType } from '../graphql/issue.type.js';
import { IssuePageType } from '../graphql/issue-page.type.js';
import { CreateIssueInput } from '../graphql/create-issue.input.js';
import { UpdateIssueInput } from '../graphql/update-issue.input.js';
import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
@Resolver(() => IssueType)
export class IssueResolver {
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,
) {}
@Query(() => IssueType, { name: 'issue' })
getById(@Args('id', { type: () => Int }) id: number): Promise<IssueDto> {
return this.handleNotFound(() => this.getIssueUseCase.execute(new GetIssueQuery(id)));
}
@Query(() => IssueType, { name: 'issueByKey' })
getByCkey(@Args('ckey') ckey: string): Promise<IssueDto> {
return this.handleNotFound(() => this.getIssueByCkeyUseCase.execute(new GetIssueByCkeyQuery(ckey)));
}
@Query(() => IssuePageType, { name: 'issues' })
list(
@Args('limit', { type: () => Int, defaultValue: 10 }) limit: number,
@Args('page', { type: () => Int, defaultValue: 0 }) page: number,
): Promise<{ list: IssueDto[]; meta: PaginationMeta }> {
return this.listIssuesUseCase.execute(new ListIssuesQuery(limit, page));
}
@Mutation(() => IssueType, { name: 'createIssue' })
create(@Args('input') input: CreateIssueInput, @Session() session: UserSession): Promise<IssueDto> {
return this.createIssueUseCase.execute(
new CreateIssueCommand(input.key, input.title, session.user.id, input.content, input.priority, input.assignee),
);
}
@Mutation(() => IssueType, { name: 'updateIssue' })
update(@Args('id', { type: () => Int }) id: number, @Args('input') input: UpdateIssueInput): Promise<IssueDto> {
return this.handleNotFound(() =>
this.updateIssueUseCase.execute(
new UpdateIssueCommand(id, input.title, input.content, input.status, input.priority, input.assignee),
),
);
}
@Mutation(() => Boolean, { name: 'deleteIssue' })
async delete(@Args('id', { type: () => Int }) id: number): Promise<boolean> {
await this.handleNotFound(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id)));
return true;
}
/** Транслирует доменное исключение в GraphQL-ошибку — это забота presentation-слоя */
private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof IssueNotFoundException) {
throw new NotFoundException(error.message);
}
throw error;
}
}
}