init
Gitea Actions Demo / Deploy (push) Failing after 18s

This commit is contained in:
vadim committed 2026-09-24 10:34:27 +03:00
commit 7b999a670c
243 files changed
+24818

No files matched your search

+19
View File
@@ -0,0 +1,19 @@
import { Controller, Get } from '@nestjs/common';
import { AllowAnonymous, Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { AppService } from './app.service.js';
@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
@AllowAnonymous()
getHello(): string {
return this.appService.getHello();
}
@Get('me')
getMe(@Session() session: UserSession) {
return session.user;
}
}
+47
View File
@@ -0,0 +1,47 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
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 { AuthModule } from '@thallesp/nestjs-better-auth';
import { auth } from './lib/auth.js';
import { ConfigModule } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'node:path';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
cache: true,
}),
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
}),
// TypeOrmModule.forRootAsync({
// imports: [ConfigModule],
// inject: [ConfigService],
// useFactory: pgConfig,
// }),
AuthModule.forRoot({
auth: auth,
bodyParser: {
json: { limit: '2mb' },
urlencoded: { limit: '2mb', extended: true },
rawBody: true,
},
}),
IssueModule,
ProjectModule,
UserModule,
StatusModule,
],
controllers: [AppController],
providers: [AppService],
})
export class AppModule {}
+8
View File
@@ -0,0 +1,8 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}
+11
View File
@@ -0,0 +1,11 @@
import { ConsoleLogger } from '@nestjs/common';
export class CustomLogger extends ConsoleLogger {
private readonly ignoredContexts = ['InstanceLoader', 'RoutesResolver', 'RouterExplorer'];
log(message: any, context?: string) {
if (context && this.ignoredContexts.includes(context)) return;
super.log(message, context);
}
}
+54
View File
@@ -0,0 +1,54 @@
import { Transform, TransformFnParams } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { applyDecorators } from '@nestjs/common';
interface ToStringOptions {
trim?: boolean;
}
interface BaseDecoratorParams {
isOptional?: boolean;
}
export interface StringDecoratorParams extends BaseDecoratorParams {}
export interface IntDecoratorParams extends BaseDecoratorParams {
min?: number;
max?: number;
}
const ToNumber = () =>
Transform(({ value }) => {
if (value === undefined || value === null || value === '') return undefined;
const parsed = Number(value);
return Number.isNaN(parsed) ? undefined : parsed;
});
const ToString = (options?: ToStringOptions) =>
Transform(({ value }: TransformFnParams) => {
if (value === undefined || value === null) return undefined;
let result = String(value);
if (options?.trim) result = result.trim();
return result;
});
export function StringDecorator(params?: StringDecoratorParams) {
const args = [IsString(), IsNotEmpty()];
if (params?.isOptional) args.push(IsOptional());
// @MaxLength(100)
return applyDecorators(...args);
}
export function IntDecorator(params?: IntDecoratorParams) {
const args = [IsInt(), ToNumber(), IsNotEmpty()];
if (params?.isOptional) args.push(IsOptional());
if (params?.min) args.push(Min(params.min));
if (params?.max) args.push(Max(params.max));
return applyDecorators(...args);
}
@@ -0,0 +1,39 @@
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common';
import type { Response } from 'express';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);
catch(exception: unknown, host: ArgumentsHost): unknown {
if (host.getType() !== 'http') {
// GraphQL уже несёт ошибки в собственном формате ({errors: [...]}) —
// возвращаем исключение как есть, чтобы Apollo отформатировал его сам.
if (!(exception instanceof HttpException)) {
this.logger.error(exception);
}
return exception;
}
const response = host.switchToHttp().getResponse<Response>();
const isHttpException = exception instanceof HttpException;
const status = isHttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR;
if (!isHttpException) {
this.logger.error(exception);
}
const exceptionResponse = isHttpException ? exception.getResponse() : null;
const message =
typeof exceptionResponse === 'string'
? exceptionResponse
: ((exceptionResponse as { message?: string | string[] } | null)?.message ?? 'Internal server error');
response.status(status).json({
data: null,
code: status,
message,
});
}
}
@@ -0,0 +1,13 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType('PaginationMeta')
export class PaginationMetaType {
@Field(() => Int)
totalCount: number;
@Field(() => Int)
page: number;
@Field(() => Int)
totalPages: number;
}
@@ -0,0 +1,60 @@
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { PaginationMeta } from '../interfaces/pagination-meta.interface.js';
export interface ResponseEnvelope<T> {
data: T | null;
code: number;
}
export interface PaginatedResponseEnvelope<T> extends ResponseEnvelope<T> {
meta: PaginationMeta;
}
interface PaginatedResult<T> {
data: T;
meta: PaginationMeta;
}
function isPaginatedResult<T>(value: unknown): value is PaginatedResult<T> {
return typeof value === 'object' && value !== null && 'data' in value && 'meta' in value;
}
@Injectable()
export class TransformResponseInterceptor<T> implements NestInterceptor<
T,
T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>
> {
intercept(
context: ExecutionContext,
next: CallHandler<T>,
): Observable<T | ResponseEnvelope<T> | PaginatedResponseEnvelope<T>> {
// GraphQL уже оборачивает ответ в {data, errors} на уровне транспорта —
// конверт применяем только к HTTP (REST) контексту, чтобы не задваивать его.
if (context.getType() !== 'http') {
return next.handle();
}
const response = context.switchToHttp().getResponse();
return next.handle().pipe(
map((result) => {
// Пагинированные ответы ({data, meta}) разворачиваем на верхний
// уровень конверта, а не оборачиваем повторно как data.data.
if (isPaginatedResult<T>(result)) {
return {
data: result.data ?? null,
meta: result.meta,
code: response.statusCode,
};
}
return {
data: result ?? null,
code: response.statusCode,
};
}),
);
}
}
@@ -0,0 +1,5 @@
export interface PaginationMeta {
totalCount: number;
page: number;
totalPages: number;
}
@@ -0,0 +1,13 @@
import { Injectable } from '@nestjs/common';
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../../generated/prisma/client.js';
@Injectable()
export class PrismaAdapter extends PrismaClient {
constructor() {
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL as string,
});
super({ adapter });
}
}
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PrismaAdapter } from './prisma.adapter.js';
@Module({
providers: [PrismaAdapter],
exports: [PrismaAdapter],
})
export class PrismaModule {}
+12
View File
@@ -0,0 +1,12 @@
import { ConfigService } from '@nestjs/config';
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
export function pgConfig(config: ConfigService): TypeOrmModuleOptions {
return {
type: 'postgres',
url: config.getOrThrow<string>('DATABASE_URL'),
cache: true,
synchronize: false,
autoLoadEntities: true,
};
}
+46
View File
@@ -0,0 +1,46 @@
import type { BetterAuthPlugin } from 'better-auth';
interface ErrorResponseBody {
message?: string;
code?: string;
}
interface ResponseDto {
data: unknown | null;
code: number;
message?: string;
}
// Better-auth не проходит через Nest pipeline (смонтирован как raw middleware),
// поэтому TransformResponseInterceptor/HttpExceptionFilter его не касаются —
// приводим ответы к тому же конверту здесь, на уровне самого better-auth.
export const authTransformResponse = () => {
return {
id: 'transform-response',
onResponse: async (response) => {
// Редиректы (OAuth callback и т.п.) должны остаться как есть.
if (response.status >= 300 && response.status < 400) return;
const contentType = response.headers.get('content-type') ?? '';
if (!contentType.includes('application/json')) return;
const body: unknown = await response.json().catch(() => null);
const isError = response.status >= 400;
const envelope: ResponseDto = isError
? {
data: null,
code: response.status,
message: (body as ErrorResponseBody)?.message ?? 'Internal server error',
}
: { data: body, code: response.status };
return {
response: new Response(JSON.stringify(envelope), {
status: response.status,
headers: response.headers,
}),
};
},
} satisfies BetterAuthPlugin;
};
+76
View File
@@ -0,0 +1,76 @@
import { betterAuth } from 'better-auth';
import { prismaAdapter } from '@better-auth/prisma-adapter';
import prisma from './prisma.js';
import EmailSender from './email-sender.js';
import { authTransformResponse } from './auth-plugins/auth-response.js';
export const auth = betterAuth({
appName: process.env.APP_NAME ?? 'app',
basePath: '/api/auth',
plugins: [
// authTransformResponse()
],
database: prismaAdapter(prisma, {
provider: 'postgresql',
}),
advanced: {
disableOriginCheck: true,
disableCSRFCheck: true,
database: {
joins: true,
},
},
hooks: {},
secret: process.env.BETTER_AUTH_SECRET,
baseURL: process.env.BETTER_AUTH_URL ?? 'http://localhost:3000',
emailAndPassword: {
enabled: true,
autoSignIn: false,
requireEmailVerification: false,
onExistingUserSignUp: async ({ user }, request) => {
console.log('onExistingUserSignUp', { user });
void EmailSender.getTransporter().sendMail({
to: user.email,
subject: 'Sign-up attempt with your email',
text: 'Someone tried to create an account using your email address. If this was you, try signing in instead. If not, you can safely ignore this email.',
});
},
sendResetPassword: async ({ user, url, token }, request) => {
console.log('sendResetPassword', { user, url, token });
void EmailSender.getTransporter().sendMail({
to: user.email,
subject: 'Reset your password',
text: `Click the link to reset your password: ${url}`,
});
},
onPasswordReset: async ({ user }, request) => {
// your logic here
console.log('onPasswordReset', { user });
void EmailSender.getTransporter().sendMail({
to: user.email,
subject: 'Reset your password result',
text: `Password for user ${user.email} has been reset.`,
});
},
},
emailVerification: {
sendOnSignIn: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
console.log('emailVerification', { user, url });
void EmailSender.getTransporter().sendMail({
to: user.email,
subject: 'Verify your email address',
text: `Click the link to verify your email: ${url}`,
});
},
},
session: {
freshAge: 0,
cookieCache: {
enabled: true,
maxAge: 30,
},
},
});
+13
View File
@@ -0,0 +1,13 @@
import nodemailer, { SendMailOptions, Transporter } from 'nodemailer';
export default class EmailSender {
static getTransporter() {
return nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.GOOGLE_APP_USER,
pass: process.env.GOOGLE_APP_PASSWORD,
},
});
}
}
+16
View File
@@ -0,0 +1,16 @@
import { PrismaPg } from '@prisma/adapter-pg';
import { PrismaClient } from '../generated/prisma/client.js';
const adapter = new PrismaPg({
connectionString: process.env.DATABASE_URL!,
});
const globalForPrisma = global as unknown as {
prisma: PrismaClient;
};
const prisma =
globalForPrisma.prisma ||
new PrismaClient({
adapter,
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
export default prisma;
+37
View File
@@ -0,0 +1,37 @@
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module.js';
import { Logger, ValidationPipe } from '@nestjs/common';
import { TransformResponseInterceptor } from './common/interceptors/transform-response.interceptor.js';
import { HttpExceptionFilter } from './common/filters/http-exception.filter.js';
import { json } from 'express';
import { CustomLogger } from './common/custom-logger/custom-logger.js';
import { ConfigService } from '@nestjs/config';
async function bootstrap() {
const logger = new Logger('bootstrap');
const app = await NestFactory.create(AppModule, {
bodyParser: false,
logger: new CustomLogger(),
});
// app.useGlobalPipes(new StandardSchemaValidationPipe());
app.setGlobalPrefix('api');
// Глобальный body-parser отключён (better-auth сам парсит своё тело),
// поэтому для GraphQL включаем JSON-парсинг точечно на его пути.
app.use('/graphql', json());
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
app.useGlobalInterceptors(new TransformResponseInterceptor());
app.useGlobalFilters(new HttpExceptionFilter());
const config = app.get(ConfigService);
const port = config.get<string>('PORT', '3000');
await app.listen(port, () => {
logger.verbose(`<<Listening on port ${port}>>`);
});
}
await bootstrap();
@@ -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,
) {}
}
@@ -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();
});
});
@@ -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);
}
}
@@ -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;
}
}
@@ -0,0 +1,149 @@
import { describe, expect, it } from 'vitest';
import { Issue } from './issue.entity.js';
import { IssueCreatedEvent } from '../events/issue-created.event.js';
describe('Issue', () => {
describe('create', () => {
it('creates a new issue with sensible defaults', () => {
const issue = Issue.create({
key: 'ISSUE-1',
title: 'Fix login bug',
reporter: 'user-1',
projectId: 'project-1',
});
expect(issue.id).toBeNull();
expect(issue.key).toBe('ISSUE-1');
expect(issue.title).toBe('Fix login bug');
expect(issue.content).toBeNull();
expect(issue.status).toBe('OPEN');
expect(issue.priority).toBe(0);
expect(issue.assignee).toBeNull();
expect(issue.reporter).toBe('user-1');
});
it('applies the provided optional fields', () => {
const issue = Issue.create({
key: 'ISSUE-2',
title: 'Improve onboarding',
reporter: 'user-1',
projectId: 'project-1',
content: 'Details here',
priority: 3,
assignee: 'user-2',
});
expect(issue.content).toBe('Details here');
expect(issue.priority).toBe(3);
expect(issue.assignee).toBe('user-2');
});
it('records an IssueCreatedEvent that can be pulled once', () => {
const issue = Issue.create({
key: 'ISSUE-3',
title: 'Fix login bug',
reporter: 'user-1',
projectId: 'project-1',
});
const events = issue.pullDomainEvents();
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(IssueCreatedEvent);
expect((events[0] as IssueCreatedEvent).issueKey).toBe('ISSUE-3');
expect(issue.pullDomainEvents()).toHaveLength(0);
});
it('rejects an invalid title', () => {
expect(() => Issue.create({ key: 'ISSUE-4', title: '', reporter: 'user-1', projectId: 'project-1' })).toThrow();
});
});
describe('reconstitute', () => {
it('rebuilds an issue from persisted data without domain events', () => {
const now = new Date('2026-01-01T00:00:00.000Z');
const issue = Issue.reconstitute({
id: 42,
key: 'ISSUE-42',
title: 'Persisted issue',
content: null,
status: 'IN_PROGRESS',
priority: 2,
assignee: 'user-3',
reporter: 'user-1',
projectId: 'project-1',
createdAt: now,
updatedAt: now,
});
expect(issue.id).toBe(42);
expect(issue.status).toBe('IN_PROGRESS');
expect(issue.pullDomainEvents()).toHaveLength(0);
});
});
describe('mutations', () => {
const build = () =>
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('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
it('updates the title and bumps updatedAt', () => {
const issue = build();
const before = issue.updatedAt;
issue.updateTitle('New title');
expect(issue.title).toBe('New title');
expect(issue.updatedAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
});
it('updates the content, including clearing it', () => {
const issue = build();
issue.updateContent('Some content');
expect(issue.content).toBe('Some content');
issue.updateContent(null);
expect(issue.content).toBeNull();
});
it('changes the status through the value object rules', () => {
const issue = build();
issue.changeStatus('DONE');
expect(issue.status).toBe('DONE');
expect(() => issue.changeStatus('NOT_A_STATUS')).toThrow();
});
it('changes the priority through the value object rules', () => {
const issue = build();
issue.changePriority(4);
expect(issue.priority).toBe(4);
expect(() => issue.changePriority(10)).toThrow();
});
it('assigns and unassigns an issue', () => {
const issue = build();
issue.assignTo('user-9');
expect(issue.assignee).toBe('user-9');
issue.unassign();
expect(issue.assignee).toBeNull();
});
});
});
@@ -0,0 +1,180 @@
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 { IssueCreatedEvent } from '../events/issue-created.event.js';
import { IssueUpdatedEvent } from '../events/issue-updated.event.js';
interface IssueProps {
id: number | null;
key: string;
title: IssueTitle;
content: string | null;
status: IssueStatus;
priority: IssuePriority;
assignee: string | null;
reporter: string;
projectId: string;
createdAt: Date;
updatedAt: Date;
}
/**
* Доменная сущность. Никогда не импортирует ничего из infrastructure/
* presentation — только чистая бизнес-логика и инварианты.
*
* `id` — Int, генерируется базой данных (SERIAL), поэтому у новой задачи
* он равен null до сохранения в репозитории. `key` (например, "ISSUE-1")
* присваивается на уровне приложения при создании и не зависит от id.
*/
export class Issue {
private domainEvents: unknown[] = [];
private constructor(private readonly props: IssueProps) {}
/** Фабричный метод для создания НОВОЙ задачи (порождает доменное событие) */
static create(params: {
key: string;
title: string;
content?: string | null;
priority?: number;
assignee?: string | null;
reporter: string;
projectId: string;
}): Issue {
const issue = new Issue({
id: null,
key: params.key,
title: IssueTitle.create(params.title),
content: params.content ?? null,
status: IssueStatus.open(),
priority: params.priority !== undefined ? IssuePriority.create(params.priority) : IssuePriority.default(),
assignee: params.assignee ?? null,
reporter: params.reporter,
projectId: params.projectId,
createdAt: new Date(),
updatedAt: new Date(),
});
issue.addDomainEvent(new IssueCreatedEvent(issue.key));
return issue;
}
/** Восстановление сущности из персистентного слоя (без событий) */
static reconstitute(props: {
id: number;
key: string;
title: string;
content: string | null;
status: string;
priority: number;
assignee: string | null;
reporter: string;
projectId: string;
createdAt: Date;
updatedAt: Date;
}): Issue {
return new Issue({
id: props.id,
key: props.key,
title: IssueTitle.create(props.title),
content: props.content,
status: IssueStatus.create(props.status),
priority: IssuePriority.create(props.priority),
assignee: props.assignee,
reporter: props.reporter,
projectId: props.projectId,
createdAt: props.createdAt,
updatedAt: props.updatedAt,
});
}
get id(): number | null {
return this.props.id;
}
get key(): string {
return this.props.key;
}
get title(): string {
return this.props.title.value;
}
get content(): string | null {
return this.props.content;
}
get status(): string {
return this.props.status.value;
}
get priority(): number {
return this.props.priority.value;
}
get assignee(): string | null {
return this.props.assignee;
}
get reporter(): string {
return this.props.reporter;
}
get projectId(): string {
return this.props.projectId;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
updateTitle(title: string): void {
this.props.title = IssueTitle.create(title);
this.touch();
}
updateContent(content: string | null): void {
this.props.content = content;
this.touch();
}
changeStatus(status: string): void {
this.props.status = IssueStatus.create(status);
this.touch();
}
changePriority(priority: number): void {
this.props.priority = IssuePriority.create(priority);
this.touch();
}
assignTo(userId: string): void {
this.props.assignee = userId;
this.touch();
}
unassign(): void {
this.props.assignee = null;
this.touch();
}
private touch(): void {
this.props.updatedAt = new Date();
this.addDomainEvent(new IssueUpdatedEvent(this.key));
}
private addDomainEvent(event: unknown): void {
this.domainEvents.push(event);
}
pullDomainEvents(): unknown[] {
const events = [...this.domainEvents];
this.domainEvents = [];
return events;
}
}
@@ -0,0 +1,6 @@
export class IssueCreatedEvent {
constructor(
public readonly issueKey: string,
public readonly occurredAt: Date = new Date(),
) {}
}
@@ -0,0 +1,6 @@
export class IssueUpdatedEvent {
constructor(
public readonly issueKey: string,
public readonly occurredAt: Date = new Date(),
) {}
}
@@ -0,0 +1,6 @@
export class IssueNotFoundException extends Error {
constructor(id: number | string) {
super(`Задача с id=${id} не найдена`);
this.name = 'IssueNotFoundException';
}
}
@@ -0,0 +1,6 @@
export class IssueProjectNotFoundException extends Error {
constructor(projectId: string) {
super(`Проект с id=${projectId} не найден`);
this.name = 'IssueProjectNotFoundException';
}
}
@@ -0,0 +1,24 @@
import { describe, expect, it } from 'vitest';
import { getCKey, splitCKey } from './get-ckey.js';
describe('getCKey', () => {
it('joins the key and id into a composite key', () => {
expect(getCKey(1, 'ISSUE')).toBe('ISSUE-1');
});
});
describe('splitCKey', () => {
it('splits a composite key back into id and key', () => {
expect(splitCKey('ISSUE-1')).toEqual({ id: 1, key: 'ISSUE' });
});
it('round-trips with getCKey', () => {
const ckey = getCKey(42, 'BUG');
expect(splitCKey(ckey)).toEqual({ id: 42, key: 'BUG' });
});
it('key has hyphen', () => {
const ckey = getCKey(42, 'BUG--');
expect(splitCKey(ckey)).toEqual({ id: 42, key: 'BUG--' });
});
});
@@ -0,0 +1,14 @@
// create composite key
export function getCKey(id: number, key: string) {
return `${key}-${id}`;
}
// split composite key
export function splitCKey(ckey: string) {
const arr = ckey.split('-');
const id = Number(arr.at(-1));
arr.pop();
return { id: id, key: arr.join('-') };
}
@@ -0,0 +1,22 @@
import { Issue } from '../entities/issue.entity.js';
import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const ISSUE_REPOSITORY = Symbol('ISSUE_REPOSITORY');
export interface IIssueRepository {
/** Задача видна только пользователю, который является в ней reporter или assignee */
findById(id: number, userId: string): Promise<Issue | null>;
findByCKey(ckey: string, userId: string): Promise<Issue | null>;
findAll(params: {
limit: number;
page: number;
userId: string;
status?: string;
projectId?: string;
}): Promise<{ list: Issue[]; meta: PaginationMeta }>;
countByStatus(userId: string): Promise<Array<{ status: string; count: number }>>;
create(issue: Issue): Promise<Issue>;
update(issue: Issue): Promise<void>;
delete(id: number): Promise<void>;
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { IssuePriority } from './issue-priority.vo.js';
describe('IssuePriority', () => {
it('accepts integers within the 0..4 range', () => {
expect(IssuePriority.create(0).value).toBe(0);
expect(IssuePriority.create(4).value).toBe(4);
});
it('defaults to 0', () => {
expect(IssuePriority.default().value).toBe(0);
});
it('throws for a non-integer value', () => {
expect(() => IssuePriority.create(1.5)).toThrow('Приоритет задачи должен быть целым числом');
});
it('throws for a value below the minimum', () => {
expect(() => IssuePriority.create(-1)).toThrow('Приоритет задачи должен быть в диапазоне от 0 до 4');
});
it('throws for a value above the maximum', () => {
expect(() => IssuePriority.create(5)).toThrow('Приоритет задачи должен быть в диапазоне от 0 до 4');
});
it('compares two priorities by value', () => {
expect(IssuePriority.create(2).equals(IssuePriority.create(2))).toBe(true);
expect(IssuePriority.create(2).equals(IssuePriority.create(3))).toBe(false);
});
});
@@ -0,0 +1,30 @@
export class IssuePriority {
private static readonly MIN = 0;
private static readonly MAX = 4;
private constructor(private readonly _value: number) {}
static create(value: number): IssuePriority {
if (!Number.isInteger(value)) {
throw new Error('Приоритет задачи должен быть целым числом');
}
if (value < IssuePriority.MIN || value > IssuePriority.MAX) {
throw new Error(`Приоритет задачи должен быть в диапазоне от ${IssuePriority.MIN} до ${IssuePriority.MAX}`);
}
return new IssuePriority(value);
}
static default(): IssuePriority {
return new IssuePriority(0);
}
get value(): number {
return this._value;
}
equals(other: IssuePriority): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';
import { IssueStatus, ISSUE_STATUSES } from './issue-status.vo.js';
describe('IssueStatus', () => {
it.each(ISSUE_STATUSES.map((status) => status.alias))('accepts the valid status "%s"', (alias) => {
expect(IssueStatus.create(alias).value).toBe(alias);
});
it('throws for an unknown status', () => {
expect(() => IssueStatus.create('ARCHIVED')).toThrow(/Недопустимый статус задачи/);
});
it('defaults to OPEN via the open() factory', () => {
expect(IssueStatus.open().value).toBe('OPEN');
});
it('compares two statuses by value', () => {
const a = IssueStatus.create('DONE');
const b = IssueStatus.create('DONE');
const c = IssueStatus.create('OPEN');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});
@@ -0,0 +1,34 @@
export const ISSUE_STATUSES = [
{ name: 'Open', alias: 'OPEN', color: 'surface-light' },
{ name: 'In progress', alias: 'IN_PROGRESS', color: 'warning' },
{ name: 'Done', alias: 'DONE', color: 'success' },
{ name: 'Cancelled', alias: 'CANCELLED', color: 'primary' },
] as const;
export type IssueStatusValue = (typeof ISSUE_STATUSES)[number]['alias'];
const ISSUE_STATUS_ALIASES = ISSUE_STATUSES.map((status) => status.alias);
export class IssueStatus {
private constructor(private readonly _value: IssueStatusValue) {}
static create(value: string): IssueStatus {
if (!ISSUE_STATUS_ALIASES.includes(value as IssueStatusValue)) {
throw new Error(`Недопустимый статус задачи: "${value}". Разрешены: ${ISSUE_STATUS_ALIASES.join(', ')}`);
}
return new IssueStatus(value as IssueStatusValue);
}
static open(): IssueStatus {
return new IssueStatus('OPEN');
}
get value(): IssueStatusValue {
return this._value;
}
equals(other: IssueStatus): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,35 @@
import { describe, expect, it } from 'vitest';
import { IssueTitle } from './issue-title.vo.js';
describe('IssueTitle', () => {
it('trims and stores a valid title', () => {
const title = IssueTitle.create(' Fix login bug ');
expect(title.value).toBe('Fix login bug');
});
it('throws when the title is empty', () => {
expect(() => IssueTitle.create(' ')).toThrow('Заголовок задачи не может быть пустым');
});
it('throws when the title exceeds 200 characters', () => {
const tooLong = 'a'.repeat(201);
expect(() => IssueTitle.create(tooLong)).toThrow('Заголовок задачи не может превышать 200 символов');
});
it('accepts a title of exactly 200 characters', () => {
const maxLength = 'a'.repeat(200);
expect(IssueTitle.create(maxLength).value).toBe(maxLength);
});
it('compares two titles by value', () => {
const a = IssueTitle.create('Same');
const b = IssueTitle.create('Same');
const c = IssueTitle.create('Different');
expect(a.equals(b)).toBe(true);
expect(a.equals(c)).toBe(false);
});
});
@@ -0,0 +1,27 @@
export class IssueTitle {
private static readonly MAX_LENGTH = 200;
private constructor(private readonly _value: string) {}
static create(value: string): IssueTitle {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error('Заголовок задачи не может быть пустым');
}
if (trimmed.length > IssueTitle.MAX_LENGTH) {
throw new Error(`Заголовок задачи не может превышать ${IssueTitle.MAX_LENGTH} символов`);
}
return new IssueTitle(trimmed);
}
get value(): string {
return this._value;
}
equals(other: IssueTitle): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import { Issue } from '../../../../domain/entities/issue.entity.js';
import { IssuePrismaMapper } from './issue.mapper.js';
describe('IssuePrismaMapper', () => {
const record = {
id: 5,
key: 'ISSUE',
title: 'Fix login bug',
content: 'Details',
status: 'IN_PROGRESS',
priority: 2,
assigneeId: 'user-2',
reporterId: 'user-1',
projectId: 'project-1',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-02T00:00:00.000Z'),
};
it('maps a Prisma record to a domain entity', () => {
const issue = IssuePrismaMapper.toDomain(record);
expect(issue.id).toBe(5);
expect(issue.key).toBe('ISSUE-5');
expect(issue.title).toBe('Fix login bug');
expect(issue.status).toBe('IN_PROGRESS');
expect(issue.priority).toBe(2);
expect(issue.assignee).toBe('user-2');
});
it('maps a new domain entity to Prisma create input', () => {
const issue = Issue.create({
key: 'ISSUE-6',
title: 'New issue',
reporter: 'user-1',
projectId: 'project-1',
});
const input = IssuePrismaMapper.toCreateInput(issue);
expect(input).toMatchObject({
key: 'ISSUE-6',
title: 'New issue',
content: null,
status: 'OPEN',
priority: 0,
assigneeId: null,
reporterId: 'user-1',
projectId: 'project-1',
});
});
it('maps a domain entity to Prisma update input without the immutable fields', () => {
const issue = IssuePrismaMapper.toDomain(record);
issue.updateTitle('Updated title');
const input = IssuePrismaMapper.toUpdateInput(issue);
expect(input).toMatchObject({
title: 'Updated title',
status: 'IN_PROGRESS',
priority: 2,
assigneeId: 'user-2',
});
expect(input).not.toHaveProperty('id');
expect(input).not.toHaveProperty('key');
expect(input).not.toHaveProperty('createdAt');
});
});
@@ -0,0 +1,47 @@
import type { Issue as PrismaIssue, Prisma } from '../../../../../../generated/prisma/client.js';
import { Issue } from '../../../../domain/entities/issue.entity.js';
import { getCKey } from '../../../../domain/helpers/get-ckey.js';
export class IssuePrismaMapper {
static toDomain(record: PrismaIssue): Issue {
return Issue.reconstitute({
id: record.id,
key: getCKey(record.id, record.key),
title: record.title,
content: record.content,
status: record.status,
priority: record.priority,
assignee: record.assigneeId,
reporter: record.reporterId,
projectId: record.projectId,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
static toCreateInput(issue: Issue): Prisma.IssueUncheckedCreateInput {
return {
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,
};
}
static toUpdateInput(issue: Issue): Prisma.IssueUncheckedUpdateInput {
return {
title: issue.title,
content: issue.content,
status: issue.status,
priority: issue.priority,
assigneeId: issue.assignee,
updatedAt: issue.updatedAt,
};
}
}
@@ -0,0 +1,108 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { Issue } from '../../../../domain/entities/issue.entity.js';
import { IIssueRepository } from '../../../../domain/repositories/issue.repository.interface.js';
import { IssuePrismaMapper } from '../mappers/issue.mapper.js';
import { IssueFindManyArgs } from '../../../../../../generated/prisma/models/Issue.js';
import { PaginationMeta } from '../../../../../../common/interfaces/pagination-meta.interface.js';
import { splitCKey } from '../../../../domain/helpers/get-ckey.js';
import { Prisma } from '../../../../../../generated/prisma/client.js';
import { IssueProjectNotFoundException } from '../../../../domain/exceptions/issue-project-not-found.exception.js';
const FOREIGN_KEY_VIOLATION = 'P2003';
/** Задача видна только своему reporter/assignee */
function ownedByUser(userId: string) {
return [{ assigneeId: userId }, { reporterId: userId }];
}
@Injectable()
export class IssuePrismaRepository implements IIssueRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: number, userId: string): Promise<Issue | null> {
const record = await this.prisma.issue.findFirst({
where: { id, OR: ownedByUser(userId) },
});
return record ? IssuePrismaMapper.toDomain(record) : null;
}
async findByCKey(ckey: string, userId: string): Promise<Issue | null> {
const { id, key } = splitCKey(ckey);
const record = await this.prisma.issue.findFirst({
where: { id, key, OR: ownedByUser(userId) },
});
return record ? IssuePrismaMapper.toDomain(record) : null;
}
async findAll(params: {
limit: number;
page: number;
userId: string;
status?: string;
projectId?: string;
}): Promise<{ list: Issue[]; meta: PaginationMeta }> {
const page = Math.max(1, params?.page);
const limit = Math.max(1, params?.limit);
const queryArgs: IssueFindManyArgs = {
where: {
status: params.status,
projectId: params.projectId,
OR: ownedByUser(params.userId),
},
};
const [records, totalCount] = await this.prisma.$transaction([
this.prisma.issue.findMany({
...queryArgs,
take: limit,
skip: (page - 1) * limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.issue.count({ where: queryArgs.where }),
]);
return {
list: records.map(IssuePrismaMapper.toDomain),
meta: { totalCount, page, totalPages: Math.ceil(totalCount / limit) },
};
}
async countByStatus(userId: string): Promise<Array<{ status: string; count: number }>> {
const groups = await this.prisma.issue.groupBy({
by: ['status'],
where: { OR: ownedByUser(userId) },
_count: { _all: true },
});
return groups.map((group) => ({ status: group.status, count: group._count._all }));
}
async create(issue: Issue): Promise<Issue> {
try {
const record = await this.prisma.issue.create({
data: IssuePrismaMapper.toCreateInput(issue),
});
return IssuePrismaMapper.toDomain(record);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === FOREIGN_KEY_VIOLATION) {
throw new IssueProjectNotFoundException(issue.projectId);
}
throw error;
}
}
async update(issue: Issue): Promise<void> {
await this.prisma.issue.update({
where: { id: issue.id as number },
data: IssuePrismaMapper.toUpdateInput(issue),
});
}
async delete(id: number): Promise<void> {
await this.prisma.issue.delete({ where: { id } });
}
}
+42
View File
@@ -0,0 +1,42 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.module.js';
import { ProjectModule } from '../project/project.module.js';
import { UserModule } from '../user/user.module.js';
import { IssuePrismaRepository } from './infrastructure/persistence/prisma/repositories/issue.prisma-repository.js';
import { ISSUE_REPOSITORY } from './domain/repositories/issue.repository.interface.js';
import { IssueController } from './presentation/controllers/issue.controller.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 { IssueResolver } from './presentation/resolvers/issue.resolver.js';
import { GetIssueByCkeyUseCase } from './application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.js';
import { IssueEventsService } from './application/services/issue-events.service.js';
import { IssueUserExpanderService } from './application/services/issue-user-expander.service.js';
import { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
@Module({
imports: [PrismaModule, ProjectModule, UserModule],
controllers: [IssueController],
providers: [
CreateIssueUseCase,
GetIssueUseCase,
GetIssueByCkeyUseCase,
ListIssuesUseCase,
UpdateIssueUseCase,
DeleteIssueUseCase,
GetIssueStatsUseCase,
IssueResolver,
IssueEventsService,
IssueUserExpanderService,
{
// Domain видит только интерфейс IIssueRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
provide: ISSUE_REPOSITORY,
useClass: IssuePrismaRepository,
},
],
exports: [ISSUE_REPOSITORY],
})
export class IssueModule {}
@@ -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;
}
}
}
@@ -0,0 +1,8 @@
export class ProjectDto {
id: string;
key: string;
name: string;
ownerId: string;
createdAt: Date;
updatedAt: Date;
}
@@ -0,0 +1,15 @@
import { Project } from '../../domain/entities/project.entity.js';
import { ProjectDto } from '../dto/project.dto.js';
export class ProjectApplicationMapper {
static toDto(project: Project): ProjectDto {
return {
id: project.id as string,
key: project.key,
name: project.name,
ownerId: project.ownerId,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
};
}
}
@@ -0,0 +1,7 @@
export class CreateProjectCommand {
constructor(
public readonly key: string,
public readonly name: string,
public readonly ownerId: string,
) {}
}
@@ -0,0 +1,29 @@
import { Inject, Injectable } from '@nestjs/common';
import { Project } from '../../../domain/entities/project.entity.js';
import {
PROJECT_REPOSITORY,
type IProjectRepository,
} from '../../../domain/repositories/project.repository.interface.js';
import { CreateProjectCommand } from './create-project.command.js';
import { ProjectDto } from '../../dto/project.dto.js';
import { ProjectApplicationMapper } from '../../mappers/project-application.mapper.js';
@Injectable()
export class CreateProjectUseCase {
constructor(
@Inject(PROJECT_REPOSITORY)
private readonly projectRepository: IProjectRepository,
) {}
async execute(command: CreateProjectCommand): Promise<ProjectDto> {
const draft = Project.create({
key: command.key,
name: command.name,
ownerId: command.ownerId,
});
const project = await this.projectRepository.create(draft);
return ProjectApplicationMapper.toDto(project);
}
}
@@ -0,0 +1,6 @@
export class DeleteProjectCommand {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}
@@ -0,0 +1,30 @@
import { Inject, Injectable } from '@nestjs/common';
import {
PROJECT_REPOSITORY,
type IProjectRepository,
} from '../../../domain/repositories/project.repository.interface.js';
import { ProjectNotFoundException } from '../../../domain/exceptions/project-not-found.exception.js';
import { ProjectAccessDeniedException } from '../../../domain/exceptions/project-access-denied.exception.js';
import { DeleteProjectCommand } from './delete-project.command.js';
@Injectable()
export class DeleteProjectUseCase {
constructor(
@Inject(PROJECT_REPOSITORY)
private readonly projectRepository: IProjectRepository,
) {}
async execute(command: DeleteProjectCommand): Promise<void> {
const project = await this.projectRepository.findById(command.id, command.userId);
if (!project) {
throw new ProjectNotFoundException(command.id);
}
if (project.ownerId !== command.userId) {
throw new ProjectAccessDeniedException(command.id);
}
await this.projectRepository.delete(command.id);
}
}
@@ -0,0 +1,6 @@
export class GetProjectQuery {
constructor(
public readonly id: string,
public readonly userId: string,
) {}
}
@@ -0,0 +1,27 @@
import { Inject, Injectable } from '@nestjs/common';
import {
PROJECT_REPOSITORY,
type IProjectRepository,
} from '../../../domain/repositories/project.repository.interface.js';
import { ProjectNotFoundException } from '../../../domain/exceptions/project-not-found.exception.js';
import { GetProjectQuery } from './get-project.query.js';
import { ProjectDto } from '../../dto/project.dto.js';
import { ProjectApplicationMapper } from '../../mappers/project-application.mapper.js';
@Injectable()
export class GetProjectUseCase {
constructor(
@Inject(PROJECT_REPOSITORY)
private readonly projectRepository: IProjectRepository,
) {}
async execute(query: GetProjectQuery): Promise<ProjectDto> {
const project = await this.projectRepository.findById(query.id, query.userId);
if (!project) {
throw new ProjectNotFoundException(query.id);
}
return ProjectApplicationMapper.toDto(project);
}
}
@@ -0,0 +1,7 @@
export class ListProjectsQuery {
constructor(
public readonly limit: number,
public readonly page: number,
public readonly userId: string,
) {}
}
@@ -0,0 +1,27 @@
import { Inject, Injectable } from '@nestjs/common';
import {
PROJECT_REPOSITORY,
type IProjectRepository,
} from '../../../domain/repositories/project.repository.interface.js';
import { ListProjectsQuery } from './list-projects.query.js';
import { ProjectDto } from '../../dto/project.dto.js';
import { ProjectApplicationMapper } from '../../mappers/project-application.mapper.js';
import { PaginationMeta } from '../../../../../common/interfaces/pagination-meta.interface.js';
@Injectable()
export class ListProjectsUseCase {
constructor(
@Inject(PROJECT_REPOSITORY)
private readonly projectRepository: IProjectRepository,
) {}
async execute(query: ListProjectsQuery): Promise<{ list: ProjectDto[]; meta: PaginationMeta }> {
const { list, meta } = await this.projectRepository.findAll({
limit: query.limit,
page: query.page,
userId: query.userId,
});
return { list: list.map(ProjectApplicationMapper.toDto), meta };
}
}
@@ -0,0 +1,7 @@
export class UpdateProjectCommand {
constructor(
public readonly id: string,
public readonly userId: string,
public readonly name?: string,
) {}
}
@@ -0,0 +1,38 @@
import { Inject, Injectable } from '@nestjs/common';
import {
PROJECT_REPOSITORY,
type IProjectRepository,
} from '../../../domain/repositories/project.repository.interface.js';
import { ProjectNotFoundException } from '../../../domain/exceptions/project-not-found.exception.js';
import { ProjectAccessDeniedException } from '../../../domain/exceptions/project-access-denied.exception.js';
import { UpdateProjectCommand } from './update-project.command.js';
import { ProjectDto } from '../../dto/project.dto.js';
import { ProjectApplicationMapper } from '../../mappers/project-application.mapper.js';
@Injectable()
export class UpdateProjectUseCase {
constructor(
@Inject(PROJECT_REPOSITORY)
private readonly projectRepository: IProjectRepository,
) {}
async execute(command: UpdateProjectCommand): Promise<ProjectDto> {
const project = await this.projectRepository.findById(command.id, command.userId);
if (!project) {
throw new ProjectNotFoundException(command.id);
}
if (project.ownerId !== command.userId) {
throw new ProjectAccessDeniedException(command.id);
}
if (command.name !== undefined) {
project.updateName(command.name);
}
await this.projectRepository.update(project);
return ProjectApplicationMapper.toDto(project);
}
}
@@ -0,0 +1,85 @@
import { ProjectName } from '../value-objects/project-name.vo.js';
interface ProjectProps {
id: string | null;
key: string;
name: ProjectName;
ownerId: string;
createdAt: Date;
updatedAt: Date;
}
/**
* Доменная сущность. Никогда не импортирует ничего из infrastructure/
* presentation — только чистая бизнес-логика и инварианты.
*
* `id` — UUID, генерируется на уровне Prisma при сохранении, поэтому
* у нового проекта он равен null до сохранения в репозитории.
*/
export class Project {
private constructor(private readonly props: ProjectProps) {}
/** Фабричный метод для создания НОВОГО проекта */
static create(params: { key: string; name: string; ownerId: string }): Project {
return new Project({
id: null,
key: params.key,
name: ProjectName.create(params.name),
ownerId: params.ownerId,
createdAt: new Date(),
updatedAt: new Date(),
});
}
/** Восстановление сущности из персистентного слоя */
static reconstitute(props: {
id: string;
key: string;
name: string;
ownerId: string;
createdAt: Date;
updatedAt: Date;
}): Project {
return new Project({
id: props.id,
key: props.key,
name: ProjectName.create(props.name),
ownerId: props.ownerId,
createdAt: props.createdAt,
updatedAt: props.updatedAt,
});
}
get id(): string | null {
return this.props.id;
}
get key(): string {
return this.props.key;
}
get name(): string {
return this.props.name.value;
}
get ownerId(): string {
return this.props.ownerId;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
updateName(name: string): void {
this.props.name = ProjectName.create(name);
this.touch();
}
private touch(): void {
this.props.updatedAt = new Date();
}
}
@@ -0,0 +1,6 @@
export class ProjectAccessDeniedException extends Error {
constructor(id: string) {
super(`Недостаточно прав для изменения проекта с id=${id}`);
this.name = 'ProjectAccessDeniedException';
}
}
@@ -0,0 +1,6 @@
export class ProjectKeyAlreadyExistsException extends Error {
constructor(key: string) {
super(`Проект с ключом "${key}" уже существует`);
this.name = 'ProjectKeyAlreadyExistsException';
}
}
@@ -0,0 +1,6 @@
export class ProjectNotFoundException extends Error {
constructor(id: string) {
super(`Проект с id=${id} не найден`);
this.name = 'ProjectNotFoundException';
}
}
@@ -0,0 +1,20 @@
import { Project } from '../entities/project.entity.js';
import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const PROJECT_REPOSITORY = Symbol('PROJECT_REPOSITORY');
export interface IProjectRepository {
/** Проект виден владельцу и участникам проекта (ProjectMember) */
findById(id: string, userId: string): Promise<Project | null>;
findAll(params: {
limit: number;
page: number;
userId: string;
}): Promise<{ list: Project[]; meta: PaginationMeta }>;
create(project: Project): Promise<Project>;
update(project: Project): Promise<void>;
delete(id: string): Promise<void>;
/** Добавляет пользователя в участники проекта, если он ещё не владелец/участник */
addMember(projectId: string, userId: string): Promise<void>;
}
@@ -0,0 +1,27 @@
export class ProjectName {
private static readonly MAX_LENGTH = 100;
private constructor(private readonly _value: string) {}
static create(value: string): ProjectName {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error('Название проекта не может быть пустым');
}
if (trimmed.length > ProjectName.MAX_LENGTH) {
throw new Error(`Название проекта не может превышать ${ProjectName.MAX_LENGTH} символов`);
}
return new ProjectName(trimmed);
}
get value(): string {
return this._value;
}
equals(other: ProjectName): boolean {
return this._value === other.value;
}
}
@@ -0,0 +1,32 @@
import type { Project as PrismaProject, Prisma } from '../../../../../../generated/prisma/client.js';
import { Project } from '../../../../domain/entities/project.entity.js';
export class ProjectPrismaMapper {
static toDomain(record: PrismaProject): Project {
return Project.reconstitute({
id: record.id,
key: record.key,
name: record.name,
ownerId: record.ownerId,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
});
}
static toCreateInput(project: Project): Prisma.ProjectUncheckedCreateInput {
return {
key: project.key,
name: project.name,
ownerId: project.ownerId,
createdAt: project.createdAt,
updatedAt: project.updatedAt,
};
}
static toUpdateInput(project: Project): Prisma.ProjectUncheckedUpdateInput {
return {
name: project.name,
updatedAt: project.updatedAt,
};
}
}
@@ -0,0 +1,84 @@
import { Injectable } from '@nestjs/common';
import { PrismaAdapter } from '../../../../../../common/prisma-adapter/prisma.adapter.js';
import { Project } from '../../../../domain/entities/project.entity.js';
import { IProjectRepository } from '../../../../domain/repositories/project.repository.interface.js';
import { ProjectPrismaMapper } from '../mappers/project.mapper.js';
import { PaginationMeta } from '../../../../../../common/interfaces/pagination-meta.interface.js';
import { Prisma } from '../../../../../../generated/prisma/client.js';
import { ProjectKeyAlreadyExistsException } from '../../../../domain/exceptions/project-key-already-exists.exception.js';
const UNIQUE_CONSTRAINT_VIOLATION = 'P2002';
@Injectable()
export class ProjectPrismaRepository implements IProjectRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: string, userId: string): Promise<Project | null> {
const record = await this.prisma.project.findFirst({ where: { id, ...this.accessibleBy(userId) } });
return record ? ProjectPrismaMapper.toDomain(record) : null;
}
async findAll(params: {
limit: number;
page: number;
userId: string;
}): Promise<{ list: Project[]; meta: PaginationMeta }> {
const page = Math.max(1, params?.page);
const limit = Math.max(1, params?.limit);
const where = this.accessibleBy(params.userId);
const [records, totalCount] = await this.prisma.$transaction([
this.prisma.project.findMany({
where,
take: limit,
skip: (page - 1) * limit,
orderBy: { createdAt: 'desc' },
}),
this.prisma.project.count({ where }),
]);
return {
list: records.map(ProjectPrismaMapper.toDomain),
meta: { totalCount, page, totalPages: Math.ceil(totalCount / limit) },
};
}
async create(project: Project): Promise<Project> {
try {
const record = await this.prisma.project.create({
data: ProjectPrismaMapper.toCreateInput(project),
});
return ProjectPrismaMapper.toDomain(record);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === UNIQUE_CONSTRAINT_VIOLATION) {
throw new ProjectKeyAlreadyExistsException(project.key);
}
throw error;
}
}
async update(project: Project): Promise<void> {
await this.prisma.project.update({
where: { id: project.id as string },
data: ProjectPrismaMapper.toUpdateInput(project),
});
}
async delete(id: string): Promise<void> {
await this.prisma.project.delete({ where: { id } });
}
async addMember(projectId: string, userId: string): Promise<void> {
await this.prisma.projectMember.upsert({
where: { projectId_userId: { projectId, userId } },
create: { projectId, userId },
update: {},
});
}
/** Проект доступен владельцу или тому, кто состоит в участниках */
private accessibleBy(userId: string): Prisma.ProjectWhereInput {
return { OR: [{ ownerId: userId }, { members: { some: { userId } } }] };
}
}
@@ -0,0 +1,93 @@
import {
Body,
ConflictException,
Controller,
Delete,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { CreateProjectUseCase } from '../../application/use-cases/create-project/create-project.use-case.js';
import { GetProjectUseCase } from '../../application/use-cases/get-project/get-project.use-case.js';
import { ListProjectsUseCase } from '../../application/use-cases/list-projects/list-projects.use-case.js';
import { UpdateProjectUseCase } from '../../application/use-cases/update-project/update-project.use-case.js';
import { DeleteProjectUseCase } from '../../application/use-cases/delete-project/delete-project.use-case.js';
import { CreateProjectCommand } from '../../application/use-cases/create-project/create-project.command.js';
import { GetProjectQuery } from '../../application/use-cases/get-project/get-project.query.js';
import { ListProjectsQuery } from '../../application/use-cases/list-projects/list-projects.query.js';
import { UpdateProjectCommand } from '../../application/use-cases/update-project/update-project.command.js';
import { DeleteProjectCommand } from '../../application/use-cases/delete-project/delete-project.command.js';
import { CreateProjectRequestDto } from '../dto/create-project.dto.js';
import { UpdateProjectRequestDto } from '../dto/update-project.dto.js';
import { FilterProjectDto } from '../dto/filter-project.dto.js';
import { ProjectNotFoundException } from '../../domain/exceptions/project-not-found.exception.js';
import { ProjectAccessDeniedException } from '../../domain/exceptions/project-access-denied.exception.js';
import { ProjectKeyAlreadyExistsException } from '../../domain/exceptions/project-key-already-exists.exception.js';
@Controller('projects')
export class ProjectController {
constructor(
private readonly createProjectUseCase: CreateProjectUseCase,
private readonly getProjectUseCase: GetProjectUseCase,
private readonly listProjectsUseCase: ListProjectsUseCase,
private readonly updateProjectUseCase: UpdateProjectUseCase,
private readonly deleteProjectUseCase: DeleteProjectUseCase,
) {}
@Post()
async create(@Body() dto: CreateProjectRequestDto, @Session() session: UserSession) {
return this.handleErrors(() =>
this.createProjectUseCase.execute(new CreateProjectCommand(dto.key, dto.name, session.user.id)),
);
}
@Get('list')
async list(@Query() query: FilterProjectDto, @Session() session: UserSession) {
return this.listProjectsUseCase.execute(
new ListProjectsQuery(Number(query.limit), Number(query.page), session.user.id),
);
}
@Get(':id')
async getById(@Param('id') id: string, @Session() session: UserSession) {
return this.handleErrors(() => this.getProjectUseCase.execute(new GetProjectQuery(id, session.user.id)));
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateProjectRequestDto, @Session() session: UserSession) {
return this.handleErrors(() =>
this.updateProjectUseCase.execute(new UpdateProjectCommand(id, session.user.id, dto.name)),
);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id') id: string, @Session() session: UserSession) {
await this.handleErrors(() => this.deleteProjectUseCase.execute(new DeleteProjectCommand(id, session.user.id)));
}
/** Транслирует доменные исключения в HTTP-ответ — это забота presentation-слоя */
private async handleErrors<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof ProjectNotFoundException) {
throw new NotFoundException(error.message);
}
if (error instanceof ProjectAccessDeniedException) {
throw new ForbiddenException(error.message);
}
if (error instanceof ProjectKeyAlreadyExistsException) {
throw new ConflictException(error.message);
}
throw error;
}
}
}
@@ -0,0 +1,6 @@
import { StringDecorator } from '../../../../common/decorators/validators.js';
export class CreateProjectRequestDto {
@StringDecorator() key: string;
@StringDecorator() name: string;
}
@@ -0,0 +1,6 @@
import { IntDecorator } from '../../../../common/decorators/validators.js';
export class FilterProjectDto {
@IntDecorator({ isOptional: true }) limit: number = 10;
@IntDecorator({ isOptional: true }) page: number = 1;
}
@@ -0,0 +1,5 @@
import { StringDecorator } from '../../../../common/decorators/validators.js';
export class UpdateProjectRequestDto {
@StringDecorator({ isOptional: true }) name?: string;
}
@@ -0,0 +1,15 @@
import { Field, InputType } from '@nestjs/graphql';
import { IsNotEmpty, IsString } from 'class-validator';
@InputType()
export class CreateProjectInput {
@Field()
@IsString()
@IsNotEmpty()
key: string;
@Field()
@IsString()
@IsNotEmpty()
name: string;
}
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { ProjectType } from './project.type.js';
import { PaginationMetaType } from '../../../../common/graphql/pagination-meta.type.js';
@ObjectType('ProjectPage')
export class ProjectPageType {
@Field(() => [ProjectType])
list: ProjectType[];
@Field(() => PaginationMetaType)
meta: PaginationMetaType;
}
@@ -0,0 +1,22 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType('Project')
export class ProjectType {
@Field()
id: string;
@Field()
key: string;
@Field()
name: string;
@Field()
ownerId: string;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
}
Loaded 100 of 243 files, more files were not shown because too many files have changed in this diff. Show more