issue, user, status

This commit is contained in:
2026-09-18 14:32:38 +03:00
parent 33490da091
commit 7a4220aba8
123 changed files with 3950 additions and 712 deletions
+1
View File
@@ -56,3 +56,4 @@ pids
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
/src/generated/prisma
/src/schema.gql
+2 -1
View File
@@ -1,4 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all"
"trailingComma": "all",
"printWidth": 120
}
-27
View File
@@ -1,30 +1,3 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
+14
View File
@@ -0,0 +1,14 @@
npm install prisma@7 --save-dev
npm install @prisma/client @prisma/adapter-pg pg
npx prisma
npx prisma init
npx prisma migrate dev --name init
npx prisma generate
npx auth@latest generate
+971 -7
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -22,19 +22,29 @@
"test:e2e": "vitest run --config ./vitest.config.e2e.ts"
},
"dependencies": {
"@apollo/server": "^5.5.1",
"@as-integrations/express5": "^1.1.2",
"@better-auth/prisma-adapter": "^1.7.2",
"@nestjs/apollo": "^14.0.0",
"@nestjs/common": "^12.0.1",
"@nestjs/config": "^12.0.0",
"@nestjs/core": "^12.0.1",
"@nestjs/graphql": "^14.0.0",
"@nestjs/platform-express": "^12.0.1",
"@nestjs/typeorm": "^12.0.1",
"@prisma/adapter-pg": "^7.10.0",
"@prisma/client": "^7.10.0",
"@thallesp/nestjs-better-auth": "^2.7.0",
"better-auth": "^1.7.2",
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"graphql": "^17.0.2",
"nodemailer": "^10.0.0",
"pg": "^8.23.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typeorm": "^1.1.1"
"typeorm": "^1.1.1",
"valibot": "^1.4.2"
},
"devDependencies": {
"@nestjs/cli": "^12.0.0",
@@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "test" (
"id" SERIAL NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT,
"published" BOOLEAN DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "test_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,82 @@
-- CreateTable
CREATE TABLE "user" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" BOOLEAN NOT NULL DEFAULT false,
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "user_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "session" (
"id" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"token" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"ipAddress" TEXT,
"userAgent" TEXT,
"userId" TEXT NOT NULL,
CONSTRAINT "session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "account" (
"id" TEXT NOT NULL,
"issuer" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"providerId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"accessToken" TEXT,
"refreshToken" TEXT,
"idToken" TEXT,
"accessTokenExpiresAt" TIMESTAMP(3),
"refreshTokenExpiresAt" TIMESTAMP(3),
"scope" TEXT,
"password" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "verification" (
"id" TEXT NOT NULL,
"identifier" TEXT NOT NULL,
"value" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "verification_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "user_email_key" ON "user"("email");
-- CreateIndex
CREATE INDEX "session_userId_idx" ON "session"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "session_token_key" ON "session"("token");
-- CreateIndex
CREATE INDEX "account_userId_idx" ON "account"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "account_issuer_accountId_uidx" ON "account"("issuer", "accountId");
-- CreateIndex
CREATE INDEX "verification_identifier_idx" ON "verification"("identifier");
-- AddForeignKey
ALTER TABLE "session" ADD CONSTRAINT "session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "account" ADD CONSTRAINT "account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "issue" (
"id" SERIAL NOT NULL,
"key" TEXT NOT NULL,
"title" TEXT NOT NULL,
"content" TEXT,
"status" TEXT NOT NULL,
"priority" INTEGER NOT NULL DEFAULT 0,
"assigneeId" TEXT,
"reporterId" TEXT NOT NULL,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "issue_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "issue_status_assigneeId_reporterId_idx" ON "issue"("status", "assigneeId", "reporterId");
-- AddForeignKey
ALTER TABLE "issue" ADD CONSTRAINT "issue_assigneeId_fkey" FOREIGN KEY ("assigneeId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "issue" ADD CONSTRAINT "issue_reporterId_fkey" FOREIGN KEY ("reporterId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+95 -5
View File
@@ -1,8 +1,3 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// Get a free hosted Postgres database in seconds: `npx create-db`
generator client {
provider = "prisma-client"
output = "../src/generated/prisma"
@@ -11,3 +6,98 @@ generator client {
datasource db {
provider = "postgresql"
}
model Test {
id Int @id @default(autoincrement())
title String
content String?
published Boolean? @default(false)
createdAt DateTime @default(now())
@@map("test")
}
model User {
id String @id
name String
email String
emailVerified Boolean @default(false)
image String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
sessions Session[]
accounts Account[]
assigneeIssues Issue[] @relation(name: "assigneeIssues")
reporterIssues Issue[] @relation(name: "reporterIssues")
@@unique([email])
@@map("user")
}
model Session {
id String @id
expiresAt DateTime
token String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
ipAddress String?
userAgent String?
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([token])
@@index([userId])
@@map("session")
}
model Account {
id String @id
issuer String
accountId String
providerId String
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
accessToken String?
refreshToken String?
idToken String?
accessTokenExpiresAt DateTime?
refreshTokenExpiresAt DateTime?
scope String?
password String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([issuer, accountId], map: "account_issuer_accountId_uidx")
@@index([userId])
@@map("account")
}
model Verification {
id String @id
identifier String
value String
expiresAt DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([identifier])
@@map("verification")
}
model Issue {
id Int @id @default(autoincrement())
key String
title String
content String?
status String
priority Int @default(0)
assigneeId String?
assignee User? @relation(name: "assigneeIssues", fields: [assigneeId], references: [id], onDelete: Cascade)
reporterId String
reporter User @relation(name: "reporterIssues", fields: [reporterId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@index([status, assigneeId, reporterId])
@@map("issue")
}
+27 -10
View File
@@ -1,12 +1,15 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller.js';
import { AppService } from './app.service.js';
import { ArticleModule } from './modules/article/article.module.js';
import { TypeOrmModule } from '@nestjs/typeorm';
import { IssueModule } from './modules/issue/issue.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 { pgConfig } from './configs/db.config.js';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ConfigModule } from '@nestjs/config';
import { GraphQLModule } from '@nestjs/graphql';
import { ApolloDriver, type ApolloDriverConfig } from '@nestjs/apollo';
import { join } from 'node:path';
@Module({
imports: [
@@ -14,13 +17,27 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
isGlobal: true,
cache: true,
}),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: pgConfig,
GraphQLModule.forRoot<ApolloDriverConfig>({
driver: ApolloDriver,
autoSchemaFile: join(process.cwd(), 'src/schema.gql'),
sortSchema: true,
}),
AuthModule.forRoot({ auth }),
ArticleModule,
// TypeOrmModule.forRootAsync({
// imports: [ConfigModule],
// inject: [ConfigService],
// useFactory: pgConfig,
// }),
AuthModule.forRoot({
auth: auth,
bodyParser: {
json: { limit: '2mb' },
urlencoded: { limit: '2mb', extended: true },
rawBody: true,
},
}),
IssueModule,
UserModule,
StatusModule,
],
controllers: [AppController],
providers: [AppService],
+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);
}
}
+52
View File
@@ -0,0 +1,52 @@
import { Transform, TransformFnParams } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, 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());
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 {}
+3 -3
View File
@@ -1,12 +1,12 @@
import { ConfigService } from "@nestjs/config";
import { TypeOrmModuleOptions } from "@nestjs/typeorm";
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: 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;
};
+66 -8
View File
@@ -1,18 +1,76 @@
import { betterAuth } from 'better-auth';
import { Pool } from 'pg';
import { dbConfig } from '../configs/db.config.js';
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({
database: new Pool({
host: dbConfig.host,
port: dbConfig.port,
user: dbConfig.username,
password: dbConfig.password,
database: dbConfig.database,
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;
+27 -3
View File
@@ -1,13 +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, {
// Better Auth handles body parsing for its own routes; the module re-adds
// the default parsers for everything else.
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 app.listen(process.env.PORT ?? 3000);
}
await bootstrap();
@@ -1,9 +0,0 @@
export class ArticleDto {
id: string;
title: string;
content: string;
authorId: string;
published: boolean;
createdAt: Date;
updatedAt: Date;
}
@@ -1,16 +0,0 @@
import { Article } from '../../domain/entities/article.entity.js';
import { ArticleDto } from '../dto/article.dto.js';
export class ArticleApplicationMapper {
static toDto(article: Article): ArticleDto {
return {
id: article.id,
title: article.title,
content: article.content,
authorId: article.authorId,
published: article.published,
createdAt: article.createdAt,
updatedAt: article.updatedAt,
};
}
}
@@ -1,7 +0,0 @@
export class CreateArticleCommand {
constructor(
public readonly title: string,
public readonly content: string,
public readonly authorId: string,
) {}
}
@@ -1,34 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import { randomUUID } from 'crypto';
import { Article } from '../../../domain/entities/article.entity.js';
import {
ARTICLE_REPOSITORY,
type IArticleRepository,
} from '../../../domain/repositories/article.repository.interface.js';
import { CreateArticleCommand } from './create-article.command.js';
import { ArticleDto } from '../../dto/article.dto.js';
import { ArticleApplicationMapper } from '../../mappers/article-application.mapper.js';
@Injectable()
export class CreateArticleUseCase {
constructor(
@Inject(ARTICLE_REPOSITORY)
private readonly articleRepository: IArticleRepository,
) {}
async execute(command: CreateArticleCommand): Promise<ArticleDto> {
const article = Article.create({
id: randomUUID(),
title: command.title,
content: command.content,
authorId: command.authorId,
});
await this.articleRepository.save(article);
// Здесь доменные события можно передать в EventEmitter2 / шину событий
article.pullDomainEvents();
return ArticleApplicationMapper.toDto(article);
}
}
@@ -1,3 +0,0 @@
export class DeleteArticleCommand {
constructor(public readonly id: string) {}
}
@@ -1,25 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ARTICLE_REPOSITORY,
type IArticleRepository,
} from '../../../domain/repositories/article.repository.interface.js';
import { ArticleNotFoundException } from '../../../domain/exceptions/article-not-found.exception.js';
import { DeleteArticleCommand } from './delete-article.command.js';
@Injectable()
export class DeleteArticleUseCase {
constructor(
@Inject(ARTICLE_REPOSITORY)
private readonly articleRepository: IArticleRepository,
) {}
async execute(command: DeleteArticleCommand): Promise<void> {
const article = await this.articleRepository.findById(command.id);
if (!article) {
throw new ArticleNotFoundException(command.id);
}
await this.articleRepository.delete(command.id);
}
}
@@ -1,27 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ARTICLE_REPOSITORY,
type IArticleRepository,
} from '../../../domain/repositories/article.repository.interface.js';
import { ArticleNotFoundException } from '../../../domain/exceptions/article-not-found.exception.js';
import { GetArticleQuery } from './get-article.query.js';
import { ArticleDto } from '../../dto/article.dto.js';
import { ArticleApplicationMapper } from '../../mappers/article-application.mapper.js';
@Injectable()
export class GetArticleUseCase {
constructor(
@Inject(ARTICLE_REPOSITORY)
private readonly articleRepository: IArticleRepository,
) {}
async execute(query: GetArticleQuery): Promise<ArticleDto> {
const article = await this.articleRepository.findById(query.id);
if (!article) {
throw new ArticleNotFoundException(query.id);
}
return ArticleApplicationMapper.toDto(article);
}
}
@@ -1,6 +0,0 @@
export class ListArticlesQuery {
constructor(
public readonly limit: number,
public readonly offset: number,
) {}
}
@@ -1,25 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ARTICLE_REPOSITORY,
type IArticleRepository,
} from '../../../domain/repositories/article.repository.interface.js';
import { ListArticlesQuery } from './list-articles.query.js';
import { ArticleDto } from '../../dto/article.dto.js';
import { ArticleApplicationMapper } from '../../mappers/article-application.mapper.js';
@Injectable()
export class ListArticlesUseCase {
constructor(
@Inject(ARTICLE_REPOSITORY)
private readonly articleRepository: IArticleRepository,
) {}
async execute(query: ListArticlesQuery): Promise<ArticleDto[]> {
const articles = await this.articleRepository.findAll({
limit: query.limit,
offset: query.offset,
});
return articles.map(ArticleApplicationMapper.toDto);
}
}
@@ -1,7 +0,0 @@
export class UpdateArticleCommand {
constructor(
public readonly id: string,
public readonly title?: string,
public readonly content?: string,
) {}
}
@@ -1,37 +0,0 @@
import { Inject, Injectable } from '@nestjs/common';
import {
ARTICLE_REPOSITORY,
type IArticleRepository,
} from '../../../domain/repositories/article.repository.interface.js';
import { ArticleNotFoundException } from '../../../domain/exceptions/article-not-found.exception.js';
import { UpdateArticleCommand } from './update-article.command.js';
import { ArticleDto } from '../../dto/article.dto.js';
import { ArticleApplicationMapper } from '../../mappers/article-application.mapper.js';
@Injectable()
export class UpdateArticleUseCase {
constructor(
@Inject(ARTICLE_REPOSITORY)
private readonly articleRepository: IArticleRepository,
) {}
async execute(command: UpdateArticleCommand): Promise<ArticleDto> {
const article = await this.articleRepository.findById(command.id);
if (!article) {
throw new ArticleNotFoundException(command.id);
}
if (command.title !== undefined) {
article.updateTitle(command.title);
}
if (command.content !== undefined) {
article.updateContent(command.content);
}
await this.articleRepository.save(article);
return ArticleApplicationMapper.toDto(article);
}
}
-31
View File
@@ -1,31 +0,0 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ArticleTypeOrmEntity } from './infrastructure/persistence/typeorm/entities/article.typeorm-entity.js';
import { ArticleTypeOrmRepository } from './infrastructure/persistence/typeorm/repositories/article.typeorm-repository.js';
import { ARTICLE_REPOSITORY } from './domain/repositories/article.repository.interface.js';
import { ArticleController } from './presentation/controllers/article.controller.js';
import { CreateArticleUseCase } from './application/use-cases/create-article/create-article.use-case.js';
import { GetArticleUseCase } from './application/use-cases/get-article/get-article.use-case.js';
import { ListArticlesUseCase } from './application/use-cases/list-articles/list-articles.use-case.js';
import { UpdateArticleUseCase } from './application/use-cases/update-article/update-article.use-case.js';
import { DeleteArticleUseCase } from './application/use-cases/delete-article/delete-article.use-case.js';
@Module({
imports: [TypeOrmModule.forFeature([ArticleTypeOrmEntity])],
controllers: [ArticleController],
providers: [
CreateArticleUseCase,
GetArticleUseCase,
ListArticlesUseCase,
UpdateArticleUseCase,
DeleteArticleUseCase,
{
// Domain видит только интерфейс IArticleRepository,
// конкретную реализацию (TypeORM) подставляет DI-контейнер здесь
provide: ARTICLE_REPOSITORY,
useClass: ArticleTypeOrmRepository,
},
],
exports: [ARTICLE_REPOSITORY],
})
export class ArticleModule {}
@@ -1,131 +0,0 @@
import { ArticleTitle } from '../value-objects/article-title.vo.js';
import { ArticleContent } from '../value-objects/article-content.vo.js';
import { ArticleCreatedEvent } from '../events/article-created.event.js';
interface ArticleProps {
id: string;
title: ArticleTitle;
content: ArticleContent;
authorId: string;
published: boolean;
createdAt: Date;
updatedAt: Date;
}
/**
* Доменная сущность. Никогда не импортирует ничего из infrastructure/
* presentation — только чистая бизнес-логика и инварианты.
*/
export class Article {
private domainEvents: unknown[] = [];
private constructor(private readonly props: ArticleProps) {}
/** Фабричный метод для создания НОВОЙ статьи (порождает доменное событие) */
static create(params: {
id: string;
title: string;
content: string;
authorId: string;
}): Article {
const article = new Article({
id: params.id,
title: ArticleTitle.create(params.title),
content: ArticleContent.create(params.content),
authorId: params.authorId,
published: false,
createdAt: new Date(),
updatedAt: new Date(),
});
article.addDomainEvent(new ArticleCreatedEvent(article.id));
return article;
}
/** Восстановление сущности из персистентного слоя (без событий) */
static reconstitute(props: {
id: string;
title: string;
content: string;
authorId: string;
published: boolean;
createdAt: Date;
updatedAt: Date;
}): Article {
return new Article({
id: props.id,
title: ArticleTitle.create(props.title),
content: ArticleContent.create(props.content),
authorId: props.authorId,
published: props.published,
createdAt: props.createdAt,
updatedAt: props.updatedAt,
});
}
get id(): string {
return this.props.id;
}
get title(): string {
return this.props.title.value;
}
get content(): string {
return this.props.content.value;
}
get authorId(): string {
return this.props.authorId;
}
get published(): boolean {
return this.props.published;
}
get createdAt(): Date {
return this.props.createdAt;
}
get updatedAt(): Date {
return this.props.updatedAt;
}
updateTitle(title: string): void {
this.props.title = ArticleTitle.create(title);
this.touch();
}
updateContent(content: string): void {
this.props.content = ArticleContent.create(content);
this.touch();
}
publish(): void {
if (this.props.published) {
throw new Error('Статья уже опубликована');
}
this.props.published = true;
this.touch();
}
unpublish(): void {
this.props.published = false;
this.touch();
}
private touch(): void {
this.props.updatedAt = new Date();
}
private addDomainEvent(event: unknown): void {
this.domainEvents.push(event);
}
pullDomainEvents(): unknown[] {
const events = [...this.domainEvents];
this.domainEvents = [];
return events;
}
}
@@ -1,6 +0,0 @@
export class ArticleNotFoundException extends Error {
constructor(id: string) {
super(`Статья с id=${id} не найдена`);
this.name = 'ArticleNotFoundException';
}
}
@@ -1,11 +0,0 @@
import { Article } from '../entities/article.entity.js';
/** DI-токен, т.к. интерфейсы стираются при компиляции TS */
export const ARTICLE_REPOSITORY = Symbol('ARTICLE_REPOSITORY');
export interface IArticleRepository {
findById(id: string): Promise<Article | null>;
findAll(params: { limit: number; offset: number }): Promise<Article[]>;
save(article: Article): Promise<void>;
delete(id: string): Promise<void>;
}
@@ -1,21 +0,0 @@
export class ArticleContent {
private static readonly MIN_LENGTH = 10;
private constructor(private readonly _value: string) {}
static create(value: string): ArticleContent {
const trimmed = value?.trim();
if (!trimmed || trimmed.length < ArticleContent.MIN_LENGTH) {
throw new Error(
`Содержимое статьи должно быть не короче ${ArticleContent.MIN_LENGTH} символов`,
);
}
return new ArticleContent(trimmed);
}
get value(): string {
return this._value;
}
}
@@ -1,25 +0,0 @@
export class ArticleTitle {
private constructor(private readonly _value: string) {}
static create(value: string): ArticleTitle {
const trimmed = value?.trim();
if (!trimmed) {
throw new Error('Заголовок статьи не может быть пустым');
}
if (trimmed.length > 200) {
throw new Error('Заголовок статьи не может превышать 200 символов');
}
return new ArticleTitle(trimmed);
}
get value(): string {
return this._value;
}
equals(other: ArticleTitle): boolean {
return this._value === other.value;
}
}
@@ -1,33 +0,0 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('articles')
export class ArticleTypeOrmEntity {
@PrimaryColumn('uuid')
id: string;
@Column({ type: 'varchar', length: 200 })
title: string;
@Column({ type: 'text' })
content: string;
@Index()
@Column({ name: 'author_id', type: 'uuid' })
authorId: string;
@Column({ type: 'boolean', default: false })
published: boolean;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}
@@ -1,28 +0,0 @@
import { Article } from '../../../../domain/entities/article.entity.js';
import { ArticleTypeOrmEntity } from '../entities/article.typeorm-entity.js';
export class ArticleTypeOrmMapper {
static toDomain(entity: ArticleTypeOrmEntity): Article {
return Article.reconstitute({
id: entity.id,
title: entity.title,
content: entity.content,
authorId: entity.authorId,
published: entity.published,
createdAt: entity.createdAt,
updatedAt: entity.updatedAt,
});
}
static toPersistence(article: Article): ArticleTypeOrmEntity {
const entity = new ArticleTypeOrmEntity();
entity.id = article.id;
entity.title = article.title;
entity.content = article.content;
entity.authorId = article.authorId;
entity.published = article.published;
entity.createdAt = article.createdAt;
entity.updatedAt = article.updatedAt;
return entity;
}
}
@@ -1,39 +0,0 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Article } from '../../../../domain/entities/article.entity.js';
import { IArticleRepository } from '../../../../domain/repositories/article.repository.interface.js';
import { ArticleTypeOrmEntity } from '../entities/article.typeorm-entity.js';
import { ArticleTypeOrmMapper } from '../mappers/article.mapper.js';
@Injectable()
export class ArticleTypeOrmRepository implements IArticleRepository {
constructor(
@InjectRepository(ArticleTypeOrmEntity)
private readonly repository: Repository<ArticleTypeOrmEntity>,
) {}
async findById(id: string): Promise<Article | null> {
const entity = await this.repository.findOneBy({ id });
return entity ? ArticleTypeOrmMapper.toDomain(entity) : null;
}
async findAll(params: { limit: number; offset: number }): Promise<Article[]> {
const entities = await this.repository.find({
take: params.limit,
skip: params.offset,
order: { createdAt: 'DESC' },
});
return entities.map(ArticleTypeOrmMapper.toDomain);
}
async save(article: Article): Promise<void> {
const entity = ArticleTypeOrmMapper.toPersistence(article);
await this.repository.save(entity);
}
async delete(id: string): Promise<void> {
await this.repository.delete({ id });
}
}
@@ -1,90 +0,0 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { CreateArticleUseCase } from '../../application/use-cases/create-article/create-article.use-case.js';
import { GetArticleUseCase } from '../../application/use-cases/get-article/get-article.use-case.js';
import { ListArticlesUseCase } from '../../application/use-cases/list-articles/list-articles.use-case.js';
import { UpdateArticleUseCase } from '../../application/use-cases/update-article/update-article.use-case.js';
import { DeleteArticleUseCase } from '../../application/use-cases/delete-article/delete-article.use-case.js';
import { CreateArticleCommand } from '../../application/use-cases/create-article/create-article.command.js';
import { GetArticleQuery } from '../../application/use-cases/get-article/get-article.query.js';
import { ListArticlesQuery } from '../../application/use-cases/list-articles/list-articles.query.js';
import { UpdateArticleCommand } from '../../application/use-cases/update-article/update-article.command.js';
import { DeleteArticleCommand } from '../../application/use-cases/delete-article/delete-article.command.js';
import { CreateArticleRequestDto } from '../dto/create-article.dto.js';
import { UpdateArticleRequestDto } from '../dto/update-article.dto.js';
import { ArticleNotFoundException } from '../../domain/exceptions/article-not-found.exception.js';
import { AllowAnonymous } from '@thallesp/nestjs-better-auth';
@Controller('articles')
export class ArticleController {
constructor(
private readonly createArticleUseCase: CreateArticleUseCase,
private readonly getArticleUseCase: GetArticleUseCase,
private readonly listArticlesUseCase: ListArticlesUseCase,
private readonly updateArticleUseCase: UpdateArticleUseCase,
private readonly deleteArticleUseCase: DeleteArticleUseCase,
) {}
@Post()
create(@Body() dto: CreateArticleRequestDto) {
return this.createArticleUseCase.execute(
new CreateArticleCommand(dto.title, dto.content, dto.authorId),
);
}
@Get()
@AllowAnonymous()
list(@Query('limit') limit = 20, @Query('offset') offset = 0) {
return this.listArticlesUseCase.execute(
new ListArticlesQuery(Number(limit), Number(offset)),
);
}
@Get(':id')
@AllowAnonymous()
async getById(@Param('id') id: string) {
return this.handleNotFound(() =>
this.getArticleUseCase.execute(new GetArticleQuery(id)),
);
}
@Patch(':id')
async update(@Param('id') id: string, @Body() dto: UpdateArticleRequestDto) {
return this.handleNotFound(() =>
this.updateArticleUseCase.execute(
new UpdateArticleCommand(id, dto.title, dto.content),
),
);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id') id: string) {
await this.handleNotFound(() =>
this.deleteArticleUseCase.execute(new DeleteArticleCommand(id)),
);
}
/** Транслирует доменное исключение в HTTP-ответ — это забота presentation-слоя */
private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof ArticleNotFoundException) {
throw new NotFoundException(error.message);
}
throw error;
}
}
}
@@ -1,15 +0,0 @@
import { IsNotEmpty, IsString, IsUUID, MaxLength } from 'class-validator';
export class CreateArticleRequestDto {
@IsString()
@IsNotEmpty()
@MaxLength(200)
title: string;
@IsString()
@IsNotEmpty()
content: string;
@IsUUID()
authorId: string;
}
@@ -1,12 +0,0 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateArticleRequestDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
content?: string;
}
@@ -0,0 +1,10 @@
export class IssueStatusCountDto {
status: string;
label: string;
count: number;
}
export class IssueStatsDto {
total: number;
byStatus: IssueStatusCountDto[];
}
@@ -0,0 +1,12 @@
export class IssueDto {
id: number;
key: string;
title: string;
content: string | null;
status: string;
priority: number;
assignee: string | null;
reporter: string;
createdAt: Date;
updatedAt: Date;
}
@@ -0,0 +1,19 @@
import { Issue } from '../../domain/entities/issue.entity.js';
import { IssueDto } from '../dto/issue.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,
assignee: issue.assignee,
reporter: issue.reporter,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
};
}
}
@@ -0,0 +1,21 @@
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>();
emitIssueCreated(issue: IssueDto): void {
this.issueCreated$.next(issue);
}
onIssueCreated(): Observable<IssueDto> {
return this.issueCreated$.asObservable();
}
}
@@ -0,0 +1,10 @@
export class CreateIssueCommand {
constructor(
public readonly key: string,
public readonly title: string,
public readonly reporter: string,
public readonly content?: string,
public readonly priority?: number,
public readonly assignee?: string,
) {}
}
@@ -0,0 +1,97 @@
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 { CreateIssueUseCase } from './create-issue.use-case.js';
import { CreateIssueCommand } from './create-issue.command.js';
import { IssueEventsService } from '../../services/issue-events.service.js';
describe('CreateIssueUseCase', () => {
let repository: IIssueRepository;
let issueEventsService: IssueEventsService;
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(),
};
issueEventsService = new IssueEventsService();
useCase = new CreateIssueUseCase(repository, issueEventsService);
});
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,
createdAt: issue.createdAt,
updatedAt: issue.updatedAt,
}),
);
const dto = await useCase.execute(new CreateIssueCommand('ISSUE-1', 'Fix login bug', 'user-1'));
expect(repository.create).toHaveBeenCalledTimes(1);
expect(dto).toMatchObject({
id: 7,
key: 'ISSUE-1',
title: 'Fix login bug',
status: 'OPEN',
priority: 0,
reporter: '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', 'Some content', 3, 'user-2'),
);
expect(dto.content).toBe('Some content');
expect(dto.priority).toBe(3);
expect(dto.assignee).toBe('user-2');
});
it('rejects an invalid title before touching the repository', async () => {
await expect(useCase.execute(new CreateIssueCommand('ISSUE-3', '', 'user-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,
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'));
expect(received).toEqual([dto]);
});
});
@@ -0,0 +1,38 @@
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 { CreateIssueCommand } from './create-issue.command.js';
import { IssueDto } from '../../dto/issue.dto.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { IssueEventsService } from '../../services/issue-events.service.js';
import { IssueCreatedEvent } from '../../../domain/events/issue-created.event.js';
@Injectable()
export class CreateIssueUseCase {
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
private readonly issueEventsService: IssueEventsService,
) {}
async execute(command: CreateIssueCommand): Promise<IssueDto> {
const draft = Issue.create({
key: command.key,
title: command.title,
content: command.content,
priority: command.priority,
assignee: command.assignee,
reporter: command.reporter,
});
const issue = await this.issueRepository.create(draft);
const dto = IssueApplicationMapper.toDto(issue);
const domainEvents = draft.pullDomainEvents();
if (domainEvents.some((event) => event instanceof IssueCreatedEvent)) {
this.issueEventsService.emitIssueCreated(dto);
}
return dto;
}
}
@@ -0,0 +1,3 @@
export class DeleteIssueCommand {
constructor(public readonly id: number) {}
}
@@ -0,0 +1,52 @@
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';
describe('DeleteIssueUseCase', () => {
let repository: IIssueRepository;
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(),
};
useCase = new DeleteIssueUseCase(repository);
});
it('deletes an existing issue', async () => {
vi.mocked(repository.findById).mockResolvedValue(
Issue.reconstitute({
id: 1,
key: 'ISSUE-1',
title: 'Title',
content: null,
status: 'OPEN',
priority: 0,
assignee: null,
reporter: 'user-1',
createdAt: new Date(),
updatedAt: new Date(),
}),
);
await useCase.execute(new DeleteIssueCommand(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))).rejects.toThrow(IssueNotFoundException);
expect(repository.delete).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,22 @@
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';
@Injectable()
export class DeleteIssueUseCase {
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
) {}
async execute(command: DeleteIssueCommand): Promise<void> {
const issue = await this.issueRepository.findById(command.id);
if (!issue) {
throw new IssueNotFoundException(command.id);
}
await this.issueRepository.delete(command.id);
}
}
@@ -0,0 +1,3 @@
export class GetIssueByCkeyQuery {
constructor(public readonly ckey: string) {}
}
@@ -0,0 +1,52 @@
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';
describe('GetIssueByCkeyUseCase', () => {
let repository: IIssueRepository;
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(),
};
useCase = new GetIssueByCkeyUseCase(repository);
});
it('returns the issue as a DTO when it exists', async () => {
const issue = Issue.reconstitute({
id: 1,
key: 'ISSUE-1',
title: 'Fix login bug',
content: null,
status: 'OPEN',
priority: 0,
assignee: null,
reporter: 'user-1',
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(repository.findByCKey).mockResolvedValue(issue);
const dto = await useCase.execute(new GetIssueByCkeyQuery('ISSUE-1'));
expect(repository.findByCKey).toHaveBeenCalledWith('ISSUE-1');
expect(dto.id).toBe(1);
expect(dto.key).toBe('ISSUE-1');
});
it('throws IssueNotFoundException when the issue does not exist', async () => {
vi.mocked(repository.findByCKey).mockResolvedValue(null);
await expect(useCase.execute(new GetIssueByCkeyQuery('MISSING-999'))).rejects.toThrow(IssueNotFoundException);
});
});
@@ -0,0 +1,24 @@
import { Inject, Injectable } from '@nestjs/common';
import { type IIssueRepository, ISSUE_REPOSITORY } from '../../../domain/repositories/issue.repository.interface.js';
import { IssueDto } from '../../dto/issue.dto.js';
import { IssueNotFoundException } from '../../../domain/exceptions/issue-not-found.exception.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { GetIssueByCkeyQuery } from './get-issue-by-ckey.query.js';
@Injectable()
export class GetIssueByCkeyUseCase {
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
) {}
async execute(query: GetIssueByCkeyQuery): Promise<IssueDto> {
const issue = await this.issueRepository.findByCKey(query.ckey);
if (!issue) {
throw new IssueNotFoundException(query.ckey);
}
return IssueApplicationMapper.toDto(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();
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();
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(): Promise<IssueStatsDto> {
const counts = await this.issueRepository.countByStatus();
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,3 @@
export class GetIssueQuery {
constructor(public readonly id: number) {}
}
@@ -0,0 +1,52 @@
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';
describe('GetIssueUseCase', () => {
let repository: IIssueRepository;
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(),
};
useCase = new GetIssueUseCase(repository);
});
it('returns the issue as a DTO when it exists', async () => {
const issue = Issue.reconstitute({
id: 1,
key: 'ISSUE-1',
title: 'Fix login bug',
content: null,
status: 'OPEN',
priority: 0,
assignee: null,
reporter: 'user-1',
createdAt: new Date(),
updatedAt: new Date(),
});
vi.mocked(repository.findById).mockResolvedValue(issue);
const dto = await useCase.execute(new GetIssueQuery(1));
expect(repository.findById).toHaveBeenCalledWith(1);
expect(dto.id).toBe(1);
expect(dto.key).toBe('ISSUE-1');
});
it('throws IssueNotFoundException when the issue does not exist', async () => {
vi.mocked(repository.findById).mockResolvedValue(null);
await expect(useCase.execute(new GetIssueQuery(999))).rejects.toThrow(IssueNotFoundException);
});
});
@@ -0,0 +1,24 @@
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 { IssueDto } from '../../dto/issue.dto.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
@Injectable()
export class GetIssueUseCase {
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
) {}
async execute(query: GetIssueQuery): Promise<IssueDto> {
const issue = await this.issueRepository.findById(query.id);
if (!issue) {
throw new IssueNotFoundException(query.id);
}
return IssueApplicationMapper.toDto(issue);
}
}
@@ -0,0 +1,6 @@
export class ListIssuesQuery {
constructor(
public readonly limit: number,
public readonly page: number,
) {}
}
@@ -0,0 +1,62 @@
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';
const buildIssue = (id: number) =>
Issue.reconstitute({
id,
key: `ISSUE-${id}`,
title: `Issue ${id}`,
content: null,
status: 'OPEN',
priority: 0,
assignee: null,
reporter: 'user-1',
createdAt: new Date(),
updatedAt: new Date(),
});
describe('ListIssuesUseCase', () => {
let repository: IIssueRepository;
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(),
};
useCase = new ListIssuesUseCase(repository);
});
it('forwards pagination params and maps results to DTOs', async () => {
vi.mocked(repository.findAll).mockResolvedValue({
list: [buildIssue(1), buildIssue(2)],
meta: { totalCount: 2, page: 1, totalPages: 1 },
});
const result = await useCase.execute(new ListIssuesQuery(20, 1));
expect(repository.findAll).toHaveBeenCalledWith({ limit: 20, page: 1 });
expect(result.list).toHaveLength(2);
expect(result.list.map((dto) => dto.key)).toEqual(['ISSUE-1', 'ISSUE-2']);
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 },
});
const result = await useCase.execute(new ListIssuesQuery(20, 1));
expect(result.list).toEqual([]);
});
});
@@ -0,0 +1,23 @@
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 { IssueDto } from '../../dto/issue.dto.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
import { PaginationMeta } from '../../../../../common/interfaces/pagination-meta.interface.js';
@Injectable()
export class ListIssuesUseCase {
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
) {}
async execute(query: ListIssuesQuery): Promise<{ list: IssueDto[]; meta: PaginationMeta }> {
const { list, meta } = await this.issueRepository.findAll({
limit: query.limit,
page: query.page,
});
return { list: list.map(IssueApplicationMapper.toDto), meta };
}
}
@@ -0,0 +1,10 @@
export class UpdateIssueCommand {
constructor(
public readonly id: number,
public readonly title?: string,
public readonly content?: string,
public readonly status?: string,
public readonly priority?: number,
public readonly assignee?: string | null,
) {}
}
@@ -0,0 +1,71 @@
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 { UpdateIssueUseCase } from './update-issue.use-case.js';
import { UpdateIssueCommand } from './update-issue.command.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',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
updatedAt: new Date('2026-01-01T00:00:00.000Z'),
});
describe('UpdateIssueUseCase', () => {
let repository: IIssueRepository;
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(),
};
useCase = new UpdateIssueUseCase(repository);
});
it('applies only the fields provided in the command', async () => {
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
const dto = await useCase.execute(new UpdateIssueCommand(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, undefined, undefined, undefined, undefined, 'user-9'),
);
expect(assigned.assignee).toBe('user-9');
vi.mocked(repository.findById).mockResolvedValue(buildIssue());
const unassigned = await useCase.execute(
new UpdateIssueCommand(1, undefined, undefined, undefined, undefined, null),
);
expect(unassigned.assignee).toBeNull();
});
it('throws IssueNotFoundException when the issue does not exist', async () => {
vi.mocked(repository.findById).mockResolvedValue(null);
await expect(useCase.execute(new UpdateIssueCommand(999, 'New title'))).rejects.toThrow(IssueNotFoundException);
expect(repository.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,50 @@
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 { UpdateIssueCommand } from './update-issue.command.js';
import { IssueDto } from '../../dto/issue.dto.js';
import { IssueApplicationMapper } from '../../mappers/issue-application.mapper.js';
@Injectable()
export class UpdateIssueUseCase {
constructor(
@Inject(ISSUE_REPOSITORY)
private readonly issueRepository: IIssueRepository,
) {}
async execute(command: UpdateIssueCommand): Promise<IssueDto> {
const issue = await this.issueRepository.findById(command.id);
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) {
issue.unassign();
} else {
issue.assignTo(command.assignee);
}
}
await this.issueRepository.update(issue);
return IssueApplicationMapper.toDto(issue);
}
}
@@ -0,0 +1,144 @@
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',
});
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',
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',
});
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' })).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',
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',
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,169 @@
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';
interface IssueProps {
id: number | null;
key: string;
title: IssueTitle;
content: string | null;
status: IssueStatus;
priority: IssuePriority;
assignee: string | null;
reporter: 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;
}): 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,
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;
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,
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 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();
}
private addDomainEvent(event: unknown): void {
this.domainEvents.push(event);
}
pullDomainEvents(): unknown[] {
const events = [...this.domainEvents];
this.domainEvents = [];
return events;
}
}
@@ -1,6 +1,6 @@
export class ArticleCreatedEvent {
export class IssueCreatedEvent {
constructor(
public readonly articleId: string,
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,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,15 @@
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 {
findById(id: number): Promise<Issue | null>;
findByCKey(ckey: string): Promise<Issue | null>;
findAll(params: { limit: number; page: number; status?: string }): Promise<{ list: Issue[]; meta: PaginationMeta }>;
countByStatus(): 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,66 @@
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',
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',
});
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',
});
});
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,45 @@
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,
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,
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,85 @@
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 { getCKey, splitCKey } from '../../../../domain/helpers/get-ckey.js';
@Injectable()
export class IssuePrismaRepository implements IIssueRepository {
constructor(private readonly prisma: PrismaAdapter) {}
async findById(id: number): Promise<Issue | null> {
const record = await this.prisma.issue.findUnique({ where: { id } });
return record ? IssuePrismaMapper.toDomain(record) : null;
}
async findByCKey(ckey: string): Promise<Issue | null> {
const { id, key } = splitCKey(ckey);
const record = await this.prisma.issue.findUnique({
where: { id, key },
});
return record ? IssuePrismaMapper.toDomain(record) : null;
}
async findAll(params: {
limit: number;
page: number;
status?: 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,
},
};
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(): Promise<Array<{ status: string; count: number }>> {
const groups = await this.prisma.issue.groupBy({
by: ['status'],
_count: { _all: true },
});
return groups.map((group) => ({ status: group.status, count: group._count._all }));
}
async create(issue: Issue): Promise<Issue> {
const record = await this.prisma.issue.create({
data: IssuePrismaMapper.toCreateInput(issue),
});
return IssuePrismaMapper.toDomain(record);
}
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 } });
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../common/prisma-adapter/prisma.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 { GetIssueStatsUseCase } from './application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
@Module({
imports: [PrismaModule],
controllers: [IssueController],
providers: [
CreateIssueUseCase,
GetIssueUseCase,
GetIssueByCkeyUseCase,
ListIssuesUseCase,
UpdateIssueUseCase,
DeleteIssueUseCase,
GetIssueStatsUseCase,
IssueResolver,
IssueEventsService,
{
// Domain видит только интерфейс IIssueRepository,
// конкретную реализацию (Prisma) подставляет DI-контейнер здесь
provide: ISSUE_REPOSITORY,
useClass: IssuePrismaRepository,
},
],
exports: [ISSUE_REPOSITORY],
})
export class IssueModule {}
@@ -0,0 +1,118 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
type MessageEvent,
NotFoundException,
Param,
ParseIntPipe,
Patch,
Post,
Query,
Sse,
} from '@nestjs/common';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { map, type Observable } from 'rxjs';
import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js';
import { GetIssueUseCase } from '../../application/use-cases/get-issue/get-issue.use-case.js';
import { ListIssuesUseCase } from '../../application/use-cases/list-issues/list-issues.use-case.js';
import { UpdateIssueUseCase } from '../../application/use-cases/update-issue/update-issue.use-case.js';
import { DeleteIssueUseCase } from '../../application/use-cases/delete-issue/delete-issue.use-case.js';
import { CreateIssueCommand } from '../../application/use-cases/create-issue/create-issue.command.js';
import { GetIssueQuery } from '../../application/use-cases/get-issue/get-issue.query.js';
import { ListIssuesQuery } from '../../application/use-cases/list-issues/list-issues.query.js';
import { UpdateIssueCommand } from '../../application/use-cases/update-issue/update-issue.command.js';
import { DeleteIssueCommand } from '../../application/use-cases/delete-issue/delete-issue.command.js';
import { CreateIssueRequestDto } from '../dto/create-issue.dto.js';
import { UpdateIssueRequestDto } from '../dto/update-issue.dto.js';
import { IssueNotFoundException } from '../../domain/exceptions/issue-not-found.exception.js';
import { FilterIssueDto } from '../dto/filter-issue.dto.js';
import { GetIssueByCkeyUseCase } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.js';
import { GetIssueByCkeyQuery } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.query.js';
import { IssueEventsService } from '../../application/services/issue-events.service.js';
import { GetIssueStatsUseCase } from '../../application/use-cases/get-issue-stats/get-issue-stats.use-case.js';
@Controller('issues')
export class IssueController {
constructor(
private readonly createIssueUseCase: CreateIssueUseCase,
private readonly getIssueUseCase: GetIssueUseCase,
private readonly getIssueByCkeyUseCase: GetIssueByCkeyUseCase,
private readonly listIssuesUseCase: ListIssuesUseCase,
private readonly updateIssueUseCase: UpdateIssueUseCase,
private readonly deleteIssueUseCase: DeleteIssueUseCase,
private readonly getIssueStatsUseCase: GetIssueStatsUseCase,
private readonly issueEventsService: IssueEventsService,
) {}
@Post()
create(@Body() dto: CreateIssueRequestDto, @Session() session: UserSession) {
return this.createIssueUseCase.execute(
new CreateIssueCommand(dto.key, dto.title, session.user.id, dto.content, dto.priority, dto.assignee),
);
}
@Get('list')
list(@Query() query: FilterIssueDto) {
return this.listIssuesUseCase.execute(new ListIssuesQuery(Number(query.limit), Number(query.page)));
}
@Get('stats')
stats() {
return this.getIssueStatsUseCase.execute();
}
/**
* SSE-поток для зарегистрированных пользователей (доступ уже проверен
* глобальным AuthGuard): уведомляет о каждой новой созданной задаче.
*/
@Sse('events')
issueCreatedEvents(@Session() _session: UserSession): Observable<MessageEvent> {
return this.issueEventsService.onIssueCreated().pipe(
map((issue) => ({
type: 'issue.created',
data: issue,
})),
);
}
@Get(':id')
async getById(@Param('id', ParseIntPipe) id: number) {
return this.handleNotFound(() => this.getIssueUseCase.execute(new GetIssueQuery(id)));
}
@Get('by-key/:key')
async getByCKey(@Param('key') ckey: string) {
return this.handleNotFound(() => this.getIssueByCkeyUseCase.execute(new GetIssueByCkeyQuery(ckey)));
}
@Patch(':id')
async update(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateIssueRequestDto) {
return this.handleNotFound(() =>
this.updateIssueUseCase.execute(
new UpdateIssueCommand(id, dto.title, dto.content, dto.status, dto.priority, dto.assignee),
),
);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async delete(@Param('id', ParseIntPipe) id: number) {
await this.handleNotFound(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id)));
}
/** Транслирует доменное исключение в HTTP-ответ — это забота presentation-слоя */
private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof IssueNotFoundException) {
throw new NotFoundException(error.message);
}
throw error;
}
}
}
@@ -0,0 +1,26 @@
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreateIssueRequestDto {
@IsString()
@IsNotEmpty()
key: string;
@IsString()
@IsNotEmpty()
@MaxLength(200)
title: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(4)
priority?: number;
@IsOptional()
@IsString()
assignee?: string;
}
@@ -0,0 +1,12 @@
import { IntDecorator, StringDecorator } from '../../../../common/decorators/validators.js';
export class FilterIssueDto {
@IntDecorator({ isOptional: true })
limit: number = 10;
@IntDecorator({ isOptional: true })
page: number = 1;
@StringDecorator({ isOptional: true })
name?: string;
}
@@ -0,0 +1,27 @@
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
export class UpdateIssueRequestDto {
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@IsOptional()
@IsString()
content?: string;
@IsOptional()
@IsIn(ISSUE_STATUSES.map((status) => status.alias))
status?: string;
@IsOptional()
@IsInt()
@Min(0)
@Max(4)
priority?: number;
@IsOptional()
@IsString()
assignee?: string | null;
}
@@ -0,0 +1,33 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
@InputType()
export class CreateIssueInput {
@Field()
@IsString()
@IsNotEmpty()
key: string;
@Field()
@IsString()
@IsNotEmpty()
@MaxLength(200)
title: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
content?: string;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
@Max(4)
priority?: number;
@Field({ nullable: true })
@IsOptional()
@IsString()
assignee?: string;
}
@@ -0,0 +1,12 @@
import { Field, ObjectType } from '@nestjs/graphql';
import { IssueType } from './issue.type.js';
import { PaginationMetaType } from '../../../../common/graphql/pagination-meta.type.js';
@ObjectType('IssuePage')
export class IssuePageType {
@Field(() => [IssueType])
list: IssueType[];
@Field(() => PaginationMetaType)
meta: PaginationMetaType;
}
@@ -0,0 +1,34 @@
import { Field, Int, ObjectType } from '@nestjs/graphql';
@ObjectType('Issue')
export class IssueType {
@Field(() => Int)
id: number;
@Field()
key: string;
@Field()
title: string;
@Field(() => String, { nullable: true })
content: string | null;
@Field()
status: string;
@Field(() => Int)
priority: number;
@Field(() => String, { nullable: true })
assignee: string | null;
@Field()
reporter: string;
@Field(() => Date)
createdAt: Date;
@Field(() => Date)
updatedAt: Date;
}
@@ -0,0 +1,34 @@
import { Field, InputType, Int } from '@nestjs/graphql';
import { IsIn, IsInt, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
import { ISSUE_STATUSES } from '../../domain/value-objects/issue-status.vo.js';
@InputType()
export class UpdateIssueInput {
@Field({ nullable: true })
@IsOptional()
@IsString()
@MaxLength(200)
title?: string;
@Field({ nullable: true })
@IsOptional()
@IsString()
content?: string;
@Field({ nullable: true })
@IsOptional()
@IsIn(ISSUE_STATUSES.map((status) => status.alias))
status?: string;
@Field(() => Int, { nullable: true })
@IsOptional()
@IsInt()
@Min(0)
@Max(4)
priority?: number;
@Field(() => String, { nullable: true })
@IsOptional()
@IsString()
assignee?: string | null;
}
@@ -0,0 +1,86 @@
import { NotFoundException } from '@nestjs/common';
import { Args, Int, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Session, type UserSession } from '@thallesp/nestjs-better-auth';
import { CreateIssueUseCase } from '../../application/use-cases/create-issue/create-issue.use-case.js';
import { GetIssueUseCase } from '../../application/use-cases/get-issue/get-issue.use-case.js';
import { GetIssueByCkeyUseCase } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.use-case.js';
import { ListIssuesUseCase } from '../../application/use-cases/list-issues/list-issues.use-case.js';
import { UpdateIssueUseCase } from '../../application/use-cases/update-issue/update-issue.use-case.js';
import { DeleteIssueUseCase } from '../../application/use-cases/delete-issue/delete-issue.use-case.js';
import { CreateIssueCommand } from '../../application/use-cases/create-issue/create-issue.command.js';
import { GetIssueQuery } from '../../application/use-cases/get-issue/get-issue.query.js';
import { GetIssueByCkeyQuery } from '../../application/use-cases/get-issue-by-ckey/get-issue-by-ckey.query.js';
import { ListIssuesQuery } from '../../application/use-cases/list-issues/list-issues.query.js';
import { UpdateIssueCommand } from '../../application/use-cases/update-issue/update-issue.command.js';
import { DeleteIssueCommand } from '../../application/use-cases/delete-issue/delete-issue.command.js';
import { IssueDto } from '../../application/dto/issue.dto.js';
import { IssueNotFoundException } from '../../domain/exceptions/issue-not-found.exception.js';
import { IssueType } from '../graphql/issue.type.js';
import { IssuePageType } from '../graphql/issue-page.type.js';
import { CreateIssueInput } from '../graphql/create-issue.input.js';
import { UpdateIssueInput } from '../graphql/update-issue.input.js';
import { PaginationMeta } from '../../../../common/interfaces/pagination-meta.interface.js';
@Resolver(() => IssueType)
export class IssueResolver {
constructor(
private readonly createIssueUseCase: CreateIssueUseCase,
private readonly getIssueUseCase: GetIssueUseCase,
private readonly getIssueByCkeyUseCase: GetIssueByCkeyUseCase,
private readonly listIssuesUseCase: ListIssuesUseCase,
private readonly updateIssueUseCase: UpdateIssueUseCase,
private readonly deleteIssueUseCase: DeleteIssueUseCase,
) {}
@Query(() => IssueType, { name: 'issue' })
getById(@Args('id', { type: () => Int }) id: number): Promise<IssueDto> {
return this.handleNotFound(() => this.getIssueUseCase.execute(new GetIssueQuery(id)));
}
@Query(() => IssueType, { name: 'issueByKey' })
getByCkey(@Args('ckey') ckey: string): Promise<IssueDto> {
return this.handleNotFound(() => this.getIssueByCkeyUseCase.execute(new GetIssueByCkeyQuery(ckey)));
}
@Query(() => IssuePageType, { name: 'issues' })
list(
@Args('limit', { type: () => Int, defaultValue: 10 }) limit: number,
@Args('page', { type: () => Int, defaultValue: 0 }) page: number,
): Promise<{ list: IssueDto[]; meta: PaginationMeta }> {
return this.listIssuesUseCase.execute(new ListIssuesQuery(limit, page));
}
@Mutation(() => IssueType, { name: 'createIssue' })
create(@Args('input') input: CreateIssueInput, @Session() session: UserSession): Promise<IssueDto> {
return this.createIssueUseCase.execute(
new CreateIssueCommand(input.key, input.title, session.user.id, input.content, input.priority, input.assignee),
);
}
@Mutation(() => IssueType, { name: 'updateIssue' })
update(@Args('id', { type: () => Int }) id: number, @Args('input') input: UpdateIssueInput): Promise<IssueDto> {
return this.handleNotFound(() =>
this.updateIssueUseCase.execute(
new UpdateIssueCommand(id, input.title, input.content, input.status, input.priority, input.assignee),
),
);
}
@Mutation(() => Boolean, { name: 'deleteIssue' })
async delete(@Args('id', { type: () => Int }) id: number): Promise<boolean> {
await this.handleNotFound(() => this.deleteIssueUseCase.execute(new DeleteIssueCommand(id)));
return true;
}
/** Транслирует доменное исключение в GraphQL-ошибку — это забота presentation-слоя */
private async handleNotFound<T>(fn: () => Promise<T>): Promise<T> {
try {
return await fn();
} catch (error) {
if (error instanceof IssueNotFoundException) {
throw new NotFoundException(error.message);
}
throw error;
}
}
}
@@ -0,0 +1,9 @@
import { Injectable } from '@nestjs/common';
import { ISSUE_STATUSES } from '../../../../issue/domain/value-objects/issue-status.vo.js';
@Injectable()
export class ListStatusesUseCase {
execute(): typeof ISSUE_STATUSES {
return ISSUE_STATUSES;
}
}

Some files were not shown because too many files have changed in this diff Show More