commit
7b999a670c
243 files changed
+24818
No files matched your search
@@ -0,0 +1,149 @@
|
||||
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<MessageEvent> {
|
||||
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<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
|
||||
|
||||
export class CreateIssueRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
key: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
projectId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
content?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(4)
|
||||
priority?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
assignee?: string;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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;
|
||||
|
||||
@StringDecorator({ isOptional: true })
|
||||
projectId?: 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,38 @@
|
||||
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()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
projectId: 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,44 @@
|
||||
import { Field, Int, ObjectType } from '@nestjs/graphql';
|
||||
import { UserType } from '../../../user/presentation/graphql/user.type.js';
|
||||
|
||||
@ObjectType('IssueDetail')
|
||||
export class IssueDetailType {
|
||||
@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 })
|
||||
assigneeId: string | null;
|
||||
|
||||
@Field()
|
||||
reporterId: string;
|
||||
|
||||
@Field(() => UserType, { nullable: true })
|
||||
assignee: UserType | null;
|
||||
|
||||
@Field(() => UserType)
|
||||
reporter: UserType;
|
||||
|
||||
@Field()
|
||||
projectId: string;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -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,37 @@
|
||||
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 })
|
||||
assigneeId: string | null;
|
||||
|
||||
@Field()
|
||||
reporterId: string;
|
||||
|
||||
@Field()
|
||||
projectId: 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,112 @@
|
||||
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 { IssueWithUsersDto } from '../../application/dto/issue-with-users.dto.js';
|
||||
import { IssueNotFoundException } from '../../domain/exceptions/issue-not-found.exception.js';
|
||||
import { IssueProjectNotFoundException } from '../../domain/exceptions/issue-project-not-found.exception.js';
|
||||
import { IssueType } from '../graphql/issue.type.js';
|
||||
import { IssueDetailType } from '../graphql/issue-detail.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(() => IssueDetailType, { name: 'issue' })
|
||||
getById(@Args('id', { type: () => Int }) id: number, @Session() session: UserSession): Promise<IssueWithUsersDto> {
|
||||
return this.handleErrors(() => this.getIssueUseCase.execute(new GetIssueQuery(id, session.user.id)));
|
||||
}
|
||||
|
||||
@Query(() => IssueDetailType, { name: 'issueByKey' })
|
||||
getByCkey(@Args('ckey') ckey: string, @Session() session: UserSession): Promise<IssueWithUsersDto> {
|
||||
return this.handleErrors(() => this.getIssueByCkeyUseCase.execute(new GetIssueByCkeyQuery(ckey, session.user.id)));
|
||||
}
|
||||
|
||||
@Query(() => IssuePageType, { name: 'issues' })
|
||||
list(
|
||||
@Args('limit', { type: () => Int, defaultValue: 10 }) limit: number,
|
||||
@Args('page', { type: () => Int, defaultValue: 0 }) page: number,
|
||||
@Session() session: UserSession,
|
||||
@Args('projectId', { type: () => String, nullable: true }) projectId?: string,
|
||||
): Promise<{ list: IssueWithUsersDto[]; meta: PaginationMeta }> {
|
||||
return this.listIssuesUseCase.execute(new ListIssuesQuery(limit, page, session.user.id, projectId));
|
||||
}
|
||||
|
||||
@Mutation(() => IssueType, { name: 'createIssue' })
|
||||
create(@Args('input') input: CreateIssueInput, @Session() session: UserSession): Promise<IssueWithUsersDto> {
|
||||
return this.handleErrors(() =>
|
||||
this.createIssueUseCase.execute(
|
||||
new CreateIssueCommand(
|
||||
input.key,
|
||||
input.title,
|
||||
session.user.id,
|
||||
input.projectId,
|
||||
input.content,
|
||||
input.priority,
|
||||
input.assignee,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => IssueType, { name: 'updateIssue' })
|
||||
update(
|
||||
@Args('id', { type: () => Int }) id: number,
|
||||
@Args('input') input: UpdateIssueInput,
|
||||
@Session() session: UserSession,
|
||||
): Promise<IssueWithUsersDto> {
|
||||
return this.handleErrors(() =>
|
||||
this.updateIssueUseCase.execute(
|
||||
new UpdateIssueCommand(
|
||||
id,
|
||||
session.user.id,
|
||||
input.title,
|
||||
input.content,
|
||||
input.status,
|
||||
input.priority,
|
||||
input.assignee,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean, { name: 'deleteIssue' })
|
||||
async delete(@Args('id', { type: () => Int }) id: number, @Session() session: UserSession): Promise<boolean> {
|
||||
await this.handleErrors(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id, session.user.id)));
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Транслирует доменные исключения в GraphQL-ошибку — это забота presentation-слоя */
|
||||
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof IssueNotFoundException || error instanceof IssueProjectNotFoundException) {
|
||||
throw new NotFoundException(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user