issue, user, status
This commit is contained in:
@@ -56,3 +56,4 @@ pids
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
/src/generated/prisma
|
||||
/src/schema.gql
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all"
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<!--[](https://opencollective.com/nest#backer)
|
||||
[](https://opencollective.com/nest#sponsor)-->
|
||||
|
||||
## Description
|
||||
|
||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
|
||||
@@ -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
|
||||
Generated
+971
-7
File diff suppressed because it is too large
Load Diff
+11
-1
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
-33
@@ -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;
|
||||
}
|
||||
}
|
||||
-39
@@ -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) {}
|
||||
}
|
||||
+52
@@ -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);
|
||||
});
|
||||
});
|
||||
+24
@@ -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);
|
||||
}
|
||||
}
|
||||
+47
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
+85
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user