From aaf48bb1ce47916b0c2686ae8954e0f1192891f7 Mon Sep 17 00:00:00 2001 From: MaxymBeregovoi <108676512+maksberegovoi@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:59:49 +0300 Subject: [PATCH 1/4] feat(TAS-59): configure swagger documentation and api responses - Implement unified response wrapping via TransformResponseInterceptor - Standardize error structure with ApiErrorResponseDto and global filters - Create ApiResponseHttpCodes custom decorator for automated Swagger documentation - Add generic Swagger decorators for single (ApiOkResponseDto) and paginated (ApiOkResponsePaginatedDto) responses - Enhance DTOs with @ApiProperty for detailed OpenAPI/Swagger schemas Detailed changes: - TransformResponseInterceptor: wraps all responses in { data: ... } and handles pagination (meta) - ApiErrorResponseDto: provides a consistent error object (statusCode, message, error, timestamp, path) - ApiResponseHttpCodes: a reusable helper to document common HTTP errors (400, 401, 403, 404, 409, 500) - DTOs: fully documented with @ApiProperty --- apps/backend/nest-cli.json | 18 +++ apps/backend/package.json | 1 + apps/backend/src/main.ts | 20 ++- .../src/modules/auth/auth.controller.ts | 13 +- .../backend/src/modules/auth/dto/login.dto.ts | 6 +- .../user/dto/PaginatedListExampleType.dto.ts | 9 ++ .../src/modules/user/dto/create-user.dto.ts | 4 + .../user/dto/update-auth-user-password.dto.ts | 3 + apps/backend/src/modules/user/dto/user.dto.ts | 11 ++ .../src/modules/user/user.controller.ts | 51 +++++-- .../api-response-http-codes.decorator.ts | 26 ++++ .../decorators/api-response.decorator.ts | 42 ++++++ apps/backend/src/shared/decorators/index.ts | 1 + .../src/shared/dto/api-response-error.dto.ts | 18 +++ .../src/shared/dto/api-response.dto.ts | 34 +++++ apps/backend/src/shared/dto/index.ts | 3 + .../shared/filters/all-exception.filter.ts | 26 ++-- .../shared/filters/domain-exception.filter.ts | 5 +- .../shared/filters/prisma-exception.filter.ts | 4 +- apps/backend/src/shared/interceptors/index.ts | 1 + .../transform-response.interceptor.ts | 27 ++++ .../src/shared/pipes/parse-cuid.pipe.ts | 5 +- .../src/shared/types/api-response.type.ts | 3 - apps/backend/src/shared/types/index.ts | 2 +- .../src/shared/types/paginated-result.type.ts | 6 + pnpm-lock.yaml | 124 ++++++++++-------- 26 files changed, 366 insertions(+), 97 deletions(-) create mode 100644 apps/backend/nest-cli.json create mode 100644 apps/backend/src/modules/user/dto/PaginatedListExampleType.dto.ts create mode 100644 apps/backend/src/shared/decorators/api-response-http-codes.decorator.ts create mode 100644 apps/backend/src/shared/decorators/api-response.decorator.ts create mode 100644 apps/backend/src/shared/dto/api-response-error.dto.ts create mode 100644 apps/backend/src/shared/dto/api-response.dto.ts create mode 100644 apps/backend/src/shared/dto/index.ts create mode 100644 apps/backend/src/shared/interceptors/index.ts create mode 100644 apps/backend/src/shared/interceptors/transform-response.interceptor.ts delete mode 100644 apps/backend/src/shared/types/api-response.type.ts create mode 100644 apps/backend/src/shared/types/paginated-result.type.ts diff --git a/apps/backend/nest-cli.json b/apps/backend/nest-cli.json new file mode 100644 index 0000000..60f3c09 --- /dev/null +++ b/apps/backend/nest-cli.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true, + "plugins": [ + { + "name": "@nestjs/swagger", + "options": { + "classValidatorShim": true, + "introspectComments": true, + "dtoKeyOfComment": "description" + } + } + ] + } +} diff --git a/apps/backend/package.json b/apps/backend/package.json index 5464d4e..5a98ecc 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -57,6 +57,7 @@ "passport-local": "^1.0.0", "pg": "^8.18.0", "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.2", "socket.io": "^4.8.3", "zod": "^4.3.6" }, diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 7963170..461f0df 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -7,6 +7,8 @@ import fastifyCompress from '@fastify/compress'; import fastifyCors from '@fastify/cors'; import fastifyCookie from '@fastify/cookie'; import { env } from './env'; +import { TransformResponseInterceptor } from './shared/interceptors'; +import { ApiResponse, ApiResponsePaginated } from './shared/dto'; async function bootstrap() { const PORT = Number(env.PORT); @@ -14,10 +16,7 @@ async function bootstrap() { throw new Error('Не задан порт в .env'); } - const config = new DocumentBuilder().setTitle('API').setDescription('Tracker API').build(); - const adapter = new FastifyAdapter(); - const app = await NestFactory.create(AppModule, adapter, { rawBody: true, }); @@ -47,8 +46,21 @@ async function bootstrap() { app.setGlobalPrefix('api/v1', { exclude: ['/'], }); + app.useGlobalInterceptors(new TransformResponseInterceptor()); - const document = SwaggerModule.createDocument(app, config); + const config = new DocumentBuilder() + .setTitle('Task Tracker API') + .setDescription('API documentation for Task Tracker') + .setVersion('1.0') + .addCookieAuth('access_token', { + type: 'apiKey', + in: 'cookie', + description: 'JWT token in HttpOnly cookie', + }) + .build(); + const document = SwaggerModule.createDocument(app, config, { + extraModels: [ApiResponse, ApiResponsePaginated], + }); SwaggerModule.setup('doc', app, document); await app.listen(PORT, '0.0.0.0', () => diff --git a/apps/backend/src/modules/auth/auth.controller.ts b/apps/backend/src/modules/auth/auth.controller.ts index 7f26a93..316c37e 100644 --- a/apps/backend/src/modules/auth/auth.controller.ts +++ b/apps/backend/src/modules/auth/auth.controller.ts @@ -1,11 +1,12 @@ import { Body, Controller, HttpCode, Post, Res } from '@nestjs/common'; import { AuthService } from './auth.service'; -import { ApiResponse } from '../../shared/types'; import { PrivateUserDto } from '../user/dto/user.dto'; import { CreateUserDto } from '../user/dto/create-user.dto'; import { isProd } from '../../env'; import { type FastifyReply } from 'fastify'; import { LoginRequestDto } from './dto/login.dto'; +import { ApiOkResponseDto } from '../../shared/decorators'; +import { ApiResponseHttpCodes } from '../../shared/decorators/api-response-http-codes.decorator'; @Controller('auth') export class AuthController { @@ -13,10 +14,12 @@ export class AuthController { @Post('login') @HttpCode(200) + @ApiOkResponseDto(PrivateUserDto) + @ApiResponseHttpCodes(401, 400) async login( @Body() loginUserDto: LoginRequestDto, @Res({ passthrough: true }) res: FastifyReply - ): Promise> { + ): Promise { const { accessToken, user } = await this.authService.login(loginUserDto); res.setCookie('access_token', accessToken, { @@ -26,16 +29,18 @@ export class AuthController { path: '/', }); - return { data: user }; + return user; } @Post('logout') @HttpCode(204) - async logout(@Res({ passthrough: true }) res: FastifyReply) { + @ApiResponseHttpCodes() + async logout(@Res({ passthrough: true }) res: FastifyReply): Promise { res.clearCookie('access_token', { path: '/' }); } @Post('registration') + @ApiResponseHttpCodes(409, 400) async registration(@Body() createUserDto: CreateUserDto): Promise { await this.authService.registration(createUserDto); } diff --git a/apps/backend/src/modules/auth/dto/login.dto.ts b/apps/backend/src/modules/auth/dto/login.dto.ts index 1c4211f..43c2751 100644 --- a/apps/backend/src/modules/auth/dto/login.dto.ts +++ b/apps/backend/src/modules/auth/dto/login.dto.ts @@ -1,9 +1,13 @@ -import { IsEmail, IsString } from 'class-validator'; +import { IsEmail, IsString, MinLength } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export class LoginRequestDto { + @ApiProperty({ example: 'test@mail.com' }) @IsEmail() email: string; + @ApiProperty({ example: '123456' }) @IsString() + @MinLength(6) password: string; } diff --git a/apps/backend/src/modules/user/dto/PaginatedListExampleType.dto.ts b/apps/backend/src/modules/user/dto/PaginatedListExampleType.dto.ts new file mode 100644 index 0000000..e6c487f --- /dev/null +++ b/apps/backend/src/modules/user/dto/PaginatedListExampleType.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PaginatedListExampleType { + @ApiProperty({ example: '1' }) + id: string; + + @ApiProperty({ example: 'Ivan' }) + name: string; +} diff --git a/apps/backend/src/modules/user/dto/create-user.dto.ts b/apps/backend/src/modules/user/dto/create-user.dto.ts index dcf3bc5..8e8418c 100644 --- a/apps/backend/src/modules/user/dto/create-user.dto.ts +++ b/apps/backend/src/modules/user/dto/create-user.dto.ts @@ -1,12 +1,16 @@ import { IsEmail, IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export class CreateUserDto { + @ApiProperty({ example: 'Ivan' }) @IsString() name: string; + @ApiProperty({ example: 'test@mail.com' }) @IsEmail() email: string; + @ApiProperty({ example: '123456' }) @IsString() password: string; } diff --git a/apps/backend/src/modules/user/dto/update-auth-user-password.dto.ts b/apps/backend/src/modules/user/dto/update-auth-user-password.dto.ts index a82dee2..c23e5ae 100644 --- a/apps/backend/src/modules/user/dto/update-auth-user-password.dto.ts +++ b/apps/backend/src/modules/user/dto/update-auth-user-password.dto.ts @@ -1,9 +1,12 @@ import { IsString } from 'class-validator'; +import { ApiProperty } from '@nestjs/swagger'; export class UpdateAuthUserPasswordDto { + @ApiProperty({ example: '123456' }) @IsString() oldPassword: string; + @ApiProperty({ example: '654321' }) @IsString() newPassword: string; } diff --git a/apps/backend/src/modules/user/dto/user.dto.ts b/apps/backend/src/modules/user/dto/user.dto.ts index 4d1988a..a2e26d0 100644 --- a/apps/backend/src/modules/user/dto/user.dto.ts +++ b/apps/backend/src/modules/user/dto/user.dto.ts @@ -1,14 +1,25 @@ import type { UserRole } from '@prisma/client'; import { PrivateUserPayload, PublicUserPayload } from '../selectors/user.selectors'; +import { ApiProperty } from '@nestjs/swagger'; export class PrivateUserDto implements PrivateUserPayload { + @ApiProperty({ example: 'cmnc4mk5s0000uu54csfyvooh' }) id: string; + + @ApiProperty({ example: 'Ivan' }) name: string; + + @ApiProperty({ example: 'test@mail.com' }) email: string; + + @ApiProperty({ example: 'USER' }) role: UserRole; } export class PublicUserDto implements PublicUserPayload { + @ApiProperty({ example: 'cmnc4mk5s0000uu54csfyvooh' }) id: string; + + @ApiProperty({ example: 'Ivan' }) name: string; } diff --git a/apps/backend/src/modules/user/user.controller.ts b/apps/backend/src/modules/user/user.controller.ts index adfa35d..377bef3 100644 --- a/apps/backend/src/modules/user/user.controller.ts +++ b/apps/backend/src/modules/user/user.controller.ts @@ -1,13 +1,15 @@ import { Controller, Get, Body, Patch, Param, Delete, HttpCode, UseGuards } from '@nestjs/common'; import { UserService } from './user.service'; import { UpdateUserDto } from './dto/update-user.dto'; -import { ApiResponse } from '../../shared/types'; import type { CurrentUserType } from '../auth/types/jwt-payload.type'; import { ParseCuidPipe } from '../../shared/pipes'; import { JwtAuthGuard } from '../../shared/guards'; -import { CurrentUser } from '../../shared/decorators'; +import { ApiOkResponseDto, ApiOkResponsePaginatedDto, CurrentUser } from '../../shared/decorators'; import { PrivateUserDto, PublicUserDto } from './dto/user.dto'; -import type { UpdateAuthUserPasswordDto } from './dto/update-auth-user-password.dto'; +import { UpdateAuthUserPasswordDto } from './dto/update-auth-user-password.dto'; +import { ApiResponseHttpCodes } from '../../shared/decorators/api-response-http-codes.decorator'; +import { PaginatedListExampleType } from './dto/PaginatedListExampleType.dto'; +import { PaginatedResult } from '../../shared/types'; @Controller('users') export class UserController { @@ -15,15 +17,16 @@ export class UserController { @UseGuards(JwtAuthGuard) @Get('me') - async me(@CurrentUser() user: CurrentUserType): Promise> { - const userData = await this.userService.me(user.id); - - return { data: userData }; + @ApiOkResponseDto(PrivateUserDto) + @ApiResponseHttpCodes(401) + async me(@CurrentUser() user: CurrentUserType): Promise { + return this.userService.me(user.id); } @UseGuards(JwtAuthGuard) @HttpCode(204) @Patch('me/password') + @ApiResponseHttpCodes(404, 400, 401) async updatePassword( @Body() updatePasswordDto: UpdateAuthUserPasswordDto, @CurrentUser() user: CurrentUserType @@ -33,25 +36,45 @@ export class UserController { @UseGuards(JwtAuthGuard) @Get(':id') - async findById(@Param('id', ParseCuidPipe) id: string): Promise> { - const user = await this.userService.findPublicById(id); - return { data: user }; + @ApiOkResponseDto(PublicUserDto) + @ApiResponseHttpCodes(404, 401, 400) + async findById(@Param('id', ParseCuidPipe) id: string): Promise { + return this.userService.findPublicById(id); } @UseGuards(JwtAuthGuard) @Patch('/me') + @ApiOkResponseDto(PrivateUserDto) + @ApiResponseHttpCodes(401, 404, 400) async update( @Body() updateUserDto: UpdateUserDto, @CurrentUser() user: CurrentUserType - ): Promise> { - const updatedUser = await this.userService.update(user.id, updateUserDto); - return { data: updatedUser }; + ): Promise { + return this.userService.update(user.id, updateUserDto); } @UseGuards(JwtAuthGuard) @Delete('/me') @HttpCode(204) - async remove(@CurrentUser() user: CurrentUserType) { + @ApiResponseHttpCodes(401, 404) + async remove(@CurrentUser() user: CurrentUserType): Promise { await this.userService.remove(user.id); } + + // Example for paginated list response + @Get('/PaginatedListExample') + @ApiOkResponsePaginatedDto(PaginatedListExampleType) + async PaginatedListExample(): Promise> { + const lst = [ + { id: '1', name: 'test1' }, + { id: '2', name: 'test2' }, + ]; + + return { + items: lst, + total: 2, + page: 1, + limit: 10, + }; + } } diff --git a/apps/backend/src/shared/decorators/api-response-http-codes.decorator.ts b/apps/backend/src/shared/decorators/api-response-http-codes.decorator.ts new file mode 100644 index 0000000..0e73bf5 --- /dev/null +++ b/apps/backend/src/shared/decorators/api-response-http-codes.decorator.ts @@ -0,0 +1,26 @@ +import { applyDecorators } from '@nestjs/common'; +import { ApiResponse } from '@nestjs/swagger'; +import { ApiErrorResponseDto } from '../dto'; + +const ErrorDescriptions: Record = { + 400: 'Bad Request', + 401: 'Not authorized', + 403: 'You have no access', + 404: 'Not found', + 409: 'Conflict', + 500: 'Internal server error', +}; + +export function ApiResponseHttpCodes(...codes: number[]) { + const uniqueCodes = Array.from(new Set([...codes, 500])); + + const decorators = uniqueCodes.map((code) => + ApiResponse({ + status: code, + description: ErrorDescriptions[code] || `Ошибка ${code}`, + type: ApiErrorResponseDto, + }) + ); + + return applyDecorators(...decorators); +} diff --git a/apps/backend/src/shared/decorators/api-response.decorator.ts b/apps/backend/src/shared/decorators/api-response.decorator.ts new file mode 100644 index 0000000..4b0f143 --- /dev/null +++ b/apps/backend/src/shared/decorators/api-response.decorator.ts @@ -0,0 +1,42 @@ +import { applyDecorators, Type } from '@nestjs/common'; +import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger'; +import { ApiResponse, ApiResponsePaginated } from '../dto'; + +export const ApiOkResponseDto = >(model: TModel) => { + return applyDecorators( + ApiExtraModels(ApiResponse, model), + ApiOkResponse({ + schema: { + allOf: [ + { $ref: getSchemaPath(ApiResponse) }, + { + properties: { + data: { $ref: getSchemaPath(model) }, + }, + }, + ], + }, + }) + ); +}; + +export const ApiOkResponsePaginatedDto = >(model: TModel) => { + return applyDecorators( + ApiExtraModels(ApiResponsePaginated, model), + ApiOkResponse({ + schema: { + allOf: [ + { $ref: getSchemaPath(ApiResponsePaginated) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(model) }, + }, + }, + }, + ], + }, + }) + ); +}; diff --git a/apps/backend/src/shared/decorators/index.ts b/apps/backend/src/shared/decorators/index.ts index 81b4330..ca45d49 100644 --- a/apps/backend/src/shared/decorators/index.ts +++ b/apps/backend/src/shared/decorators/index.ts @@ -1,2 +1,3 @@ export { CurrentUser } from './current-user.decorator'; export { Roles } from './roles.decorator'; +export { ApiOkResponseDto, ApiOkResponsePaginatedDto } from './api-response.decorator'; diff --git a/apps/backend/src/shared/dto/api-response-error.dto.ts b/apps/backend/src/shared/dto/api-response-error.dto.ts new file mode 100644 index 0000000..5bfd1f2 --- /dev/null +++ b/apps/backend/src/shared/dto/api-response-error.dto.ts @@ -0,0 +1,18 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ApiErrorResponseDto { + @ApiProperty({ example: 400 }) + statusCode: number; + + @ApiProperty({ example: 'Invalid value' }) + message: string | string[]; + + @ApiProperty({ example: 'BAD_REQUEST' }) + error: string; + + @ApiProperty({ example: '2026-03-31T14:00:00.000Z' }) + timestamp: string; + + @ApiProperty({ example: '/api/v1/users/me' }) + path: string; +} diff --git a/apps/backend/src/shared/dto/api-response.dto.ts b/apps/backend/src/shared/dto/api-response.dto.ts new file mode 100644 index 0000000..b74897c --- /dev/null +++ b/apps/backend/src/shared/dto/api-response.dto.ts @@ -0,0 +1,34 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ApiResponse { + @ApiProperty() + data: T; + + constructor(data: T) { + this.data = data; + } +} + +export class ApiMeta { + @ApiProperty() + total: number; + + @ApiProperty() + page: number; + + @ApiProperty() + limit: number; +} + +export class ApiResponsePaginated { + @ApiProperty({ isArray: true }) + data: T[]; + + @ApiProperty() + meta: ApiMeta; + + constructor(data: T[], meta: ApiMeta) { + this.data = data; + this.meta = meta; + } +} diff --git a/apps/backend/src/shared/dto/index.ts b/apps/backend/src/shared/dto/index.ts new file mode 100644 index 0000000..1c6b6bf --- /dev/null +++ b/apps/backend/src/shared/dto/index.ts @@ -0,0 +1,3 @@ +export { ApiResponse, ApiResponsePaginated } from './api-response.dto'; + +export { ApiErrorResponseDto } from './api-response-error.dto'; diff --git a/apps/backend/src/shared/filters/all-exception.filter.ts b/apps/backend/src/shared/filters/all-exception.filter.ts index 4817bf9..2193a82 100644 --- a/apps/backend/src/shared/filters/all-exception.filter.ts +++ b/apps/backend/src/shared/filters/all-exception.filter.ts @@ -8,6 +8,7 @@ import { } from '@nestjs/common'; import { FastifyRequest, FastifyReply } from 'fastify'; import { HttpAdapterHost } from '@nestjs/core'; +import { ApiErrorResponseDto } from '../dto'; @Catch() export class AllExceptionsFilter implements ExceptionFilter { @@ -22,29 +23,38 @@ export class AllExceptionsFilter implements ExceptionFilter { const request = ctx.getRequest(); let status = HttpStatus.INTERNAL_SERVER_ERROR; - let message: string | object = 'Internal server error'; + let message: string | string[] = 'Internal server error'; + let errorCode = 'INTERNAL_SERVER_ERROR'; if (exception instanceof HttpException) { status = exception.getStatus(); const exceptionResponse = exception.getResponse(); - message = - typeof exceptionResponse === 'object' && - exceptionResponse !== null && - 'message' in exceptionResponse - ? (exceptionResponse as { message: string | string[] }).message - : exception.message; + if (typeof exceptionResponse === 'object' && exceptionResponse !== null) { + const responseObj = exceptionResponse as { + message?: string | string[]; + error?: string; + }; + + message = responseObj.message ?? exception.message; + errorCode = responseObj.error ?? exception.name; + } else { + message = exception.message; + errorCode = exception.name; + } } else if (exception instanceof Error) { this.logger.error( `[${request.method}] ${request.url} - ${exception.message}`, exception.stack ); message = 'Internal server error. Please try again later.'; + errorCode = 'INTERNAL_SERVER_ERROR'; } - const responseBody = { + const responseBody: ApiErrorResponseDto = { statusCode: status, message: message, + error: errorCode.replace(/\s+/g, '_').toUpperCase(), timestamp: new Date().toISOString(), path: request.url, }; diff --git a/apps/backend/src/shared/filters/domain-exception.filter.ts b/apps/backend/src/shared/filters/domain-exception.filter.ts index e083ba3..0e8fef5 100644 --- a/apps/backend/src/shared/filters/domain-exception.filter.ts +++ b/apps/backend/src/shared/filters/domain-exception.filter.ts @@ -2,6 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus } from '@nestjs/commo import { FastifyRequest, FastifyReply } from 'fastify'; import { DomainError } from '../errors'; import { HttpAdapterHost } from '@nestjs/core'; +import { ApiErrorResponseDto } from '../dto'; @Catch(DomainError) export class DomainExceptionFilter implements ExceptionFilter { @@ -33,10 +34,10 @@ export class DomainExceptionFilter implements ExceptionFilter { break; } - const responseBody = { + const responseBody: ApiErrorResponseDto = { statusCode: status, message: exception.message, - error: exception.code, + error: exception.code.replace(/\s+/g, '_').toUpperCase(), timestamp: new Date().toISOString(), path: request.url, }; diff --git a/apps/backend/src/shared/filters/prisma-exception.filter.ts b/apps/backend/src/shared/filters/prisma-exception.filter.ts index 0bf4540..4212e0b 100644 --- a/apps/backend/src/shared/filters/prisma-exception.filter.ts +++ b/apps/backend/src/shared/filters/prisma-exception.filter.ts @@ -2,6 +2,7 @@ import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nest import { Prisma } from '@prisma/client'; import { FastifyRequest, FastifyReply } from 'fastify'; import { HttpAdapterHost } from '@nestjs/core'; +import { ApiErrorResponseDto } from '../dto'; @Catch(Prisma.PrismaClientKnownRequestError) export class PrismaExceptionFilter implements ExceptionFilter { @@ -54,9 +55,10 @@ export class PrismaExceptionFilter implements ExceptionFilter { ); } - const responseBody = { + const responseBody: ApiErrorResponseDto = { statusCode: status, message, + error: exception.code, timestamp: new Date().toISOString(), path: request.url, }; diff --git a/apps/backend/src/shared/interceptors/index.ts b/apps/backend/src/shared/interceptors/index.ts new file mode 100644 index 0000000..d38fa8e --- /dev/null +++ b/apps/backend/src/shared/interceptors/index.ts @@ -0,0 +1 @@ +export { TransformResponseInterceptor } from './transform-response.interceptor'; diff --git a/apps/backend/src/shared/interceptors/transform-response.interceptor.ts b/apps/backend/src/shared/interceptors/transform-response.interceptor.ts new file mode 100644 index 0000000..f5186ef --- /dev/null +++ b/apps/backend/src/shared/interceptors/transform-response.interceptor.ts @@ -0,0 +1,27 @@ +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { map, Observable } from 'rxjs'; +import { ApiResponse } from '../dto'; + +@Injectable() +export class TransformResponseInterceptor implements NestInterceptor> { + intercept(context: ExecutionContext, next: CallHandler): Observable> { + return next.handle().pipe( + map((result) => { + if (result == null) { + return { data: null }; + } + if (result.data) { + return result; + } + if (result.items && result.total !== undefined) { + const { items, ...meta } = result; + return { + data: items, + meta: meta, + }; + } + return { data: result }; + }) + ); + } +} diff --git a/apps/backend/src/shared/pipes/parse-cuid.pipe.ts b/apps/backend/src/shared/pipes/parse-cuid.pipe.ts index 149c187..a7e345e 100644 --- a/apps/backend/src/shared/pipes/parse-cuid.pipe.ts +++ b/apps/backend/src/shared/pipes/parse-cuid.pipe.ts @@ -1,4 +1,5 @@ -import { PipeTransform, BadRequestException, Injectable } from '@nestjs/common'; +import { PipeTransform, Injectable } from '@nestjs/common'; +import { DomainError } from '../errors'; //reg const CUID_REGEX = /^c[a-z0-9]{24}$/i; @@ -7,7 +8,7 @@ const CUID_REGEX = /^c[a-z0-9]{24}$/i; export class ParseCuidPipe implements PipeTransform { transform(value: string): string { if (!CUID_REGEX.test(value)) { - throw new BadRequestException('Invalid cuid'); + throw DomainError.BadRequest('Invalid id'); } return value; diff --git a/apps/backend/src/shared/types/api-response.type.ts b/apps/backend/src/shared/types/api-response.type.ts deleted file mode 100644 index 3db5cbb..0000000 --- a/apps/backend/src/shared/types/api-response.type.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface ApiResponse { - data: T; -} diff --git a/apps/backend/src/shared/types/index.ts b/apps/backend/src/shared/types/index.ts index 535d36b..aaae623 100644 --- a/apps/backend/src/shared/types/index.ts +++ b/apps/backend/src/shared/types/index.ts @@ -1 +1 @@ -export { type ApiResponse } from './api-response.type'; +export { type PaginatedResult } from './paginated-result.type'; diff --git a/apps/backend/src/shared/types/paginated-result.type.ts b/apps/backend/src/shared/types/paginated-result.type.ts new file mode 100644 index 0000000..9dbdeb9 --- /dev/null +++ b/apps/backend/src/shared/types/paginated-result.type.ts @@ -0,0 +1,6 @@ +export type PaginatedResult = { + items: T[]; + total: number; + page: number; + limit: number; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf84055..7f59350 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,37 +52,37 @@ importers: version: 9.0.0 '@nestjs/bullmq': specifier: ^11.0.4 - version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)(bullmq@5.70.1) + version: 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1) '@nestjs/common': specifier: ^11.1.14 - version: 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) + version: 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/config': specifier: ^4.0.3 - version: 4.0.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1) + version: 4.0.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2) '@nestjs/core': specifier: ^11.1.14 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nestjs/jwt': specifier: ^11.0.2 - version: 11.0.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1)) + version: 11.0.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)) '@nestjs/mapped-types': specifier: ^2.1.0 - version: 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + version: 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@nestjs/passport': specifier: ^11.0.5 - version: 11.0.5(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0) + version: 11.0.5(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0) '@nestjs/platform-fastify': specifier: ^11.1.14 - version: 11.1.14(@fastify/static@9.0.0)(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14) + version: 11.1.14(@fastify/static@9.0.0)(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) '@nestjs/platform-socket.io': specifier: ^11.1.14 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(rxjs@7.8.1) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) '@nestjs/swagger': specifier: ^11.2.6 - version: 11.2.6(@fastify/static@9.0.0)(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + version: 11.2.6(@fastify/static@9.0.0)(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) '@nestjs/websockets': specifier: ^11.1.14 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@prisma/client': specifier: 6.19.2 version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3) @@ -119,6 +119,9 @@ importers: reflect-metadata: specifier: ^0.2.2 version: 0.2.2 + rxjs: + specifier: ^7.8.2 + version: 7.8.2 socket.io: specifier: ^4.8.3 version: 4.8.3 @@ -134,7 +137,7 @@ importers: version: 11.0.9(chokidar@4.0.3)(typescript@5.9.3) '@nestjs/testing': specifier: ^11.1.14 - version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14) + version: 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) '@types/bcrypt': specifier: ^6.0.0 version: 6.0.0 @@ -6417,6 +6420,9 @@ packages: rxjs@7.8.1: resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -7062,8 +7068,8 @@ packages: undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} - undici@7.24.6: - resolution: {integrity: sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==} + undici@7.24.7: + resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} unicorn-magic@0.3.0: @@ -8696,17 +8702,17 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)': + '@nestjs/bull-shared@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)(bullmq@5.70.1)': + '@nestjs/bullmq@11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(bullmq@5.70.1)': dependencies: - '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14) - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/bull-shared': 11.0.4(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) bullmq: 5.70.1 tslib: 2.8.1 @@ -8736,13 +8742,13 @@ snapshots: - uglify-js - webpack-cli - '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: file-type: 21.3.0 iterare: 1.2.1 load-esm: 1.0.3 reflect-metadata: 0.2.2 - rxjs: 7.8.1 + rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 optionalDependencies: @@ -8751,53 +8757,53 @@ snapshots: transitivePeerDependencies: - supports-color - '@nestjs/config@4.0.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(rxjs@7.8.1)': + '@nestjs/config@4.0.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) dotenv: 17.2.3 dotenv-expand: 12.0.3 lodash: 4.17.23 - rxjs: 7.8.1 + rxjs: 7.8.2 - '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/core@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@nuxt/opencollective': 0.4.1 fast-safe-stringify: 2.1.1 iterare: 1.2.1 path-to-regexp: 8.3.0 reflect-metadata: 0.2.2 - rxjs: 7.8.1 + rxjs: 7.8.2 tslib: 2.8.1 uid: 2.0.2 optionalDependencies: - '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) - '@nestjs/jwt@11.0.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))': + '@nestjs/jwt@11.0.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@types/jsonwebtoken': 9.0.10 jsonwebtoken: 9.0.3 - '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': + '@nestjs/mapped-types@2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) reflect-metadata: 0.2.2 optionalDependencies: class-transformer: 0.5.1 class-validator: 0.14.3 - '@nestjs/passport@11.0.5(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(passport@0.7.0)': + '@nestjs/passport@11.0.5(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(passport@0.7.0)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) passport: 0.7.0 - '@nestjs/platform-fastify@11.1.14(@fastify/static@9.0.0)(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)': + '@nestjs/platform-fastify@11.1.14(@fastify/static@9.0.0)(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: '@fastify/cors': 11.2.0 '@fastify/formbody': 8.0.2 - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) fast-querystring: 1.1.2 fastify: 5.7.4 fastify-plugin: 5.1.0 @@ -8809,11 +8815,11 @@ snapshots: optionalDependencies: '@fastify/static': 9.0.0 - '@nestjs/platform-socket.io@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(rxjs@7.8.1)': + '@nestjs/platform-socket.io@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) - rxjs: 7.8.1 + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/websockets': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + rxjs: 7.8.2 socket.io: 4.8.3 tslib: 2.8.1 transitivePeerDependencies: @@ -8832,12 +8838,12 @@ snapshots: transitivePeerDependencies: - chokidar - '@nestjs/swagger@11.2.6(@fastify/static@9.0.0)(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': + '@nestjs/swagger@11.2.6(@fastify/static@9.0.0)(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)': dependencies: '@microsoft/tsdoc': 0.16.0 - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/mapped-types': 2.1.0(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2) js-yaml: 4.1.1 lodash: 4.17.23 path-to-regexp: 8.3.0 @@ -8848,23 +8854,23 @@ snapshots: class-transformer: 0.5.1 class-validator: 0.14.3 - '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)': + '@nestjs/testing@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) tslib: 2.8.1 - '@nestjs/websockets@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1)': + '@nestjs/websockets@11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(@nestjs/platform-socket.io@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)': dependencies: - '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1) - '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.1) + '@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2) + '@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2) iterare: 1.2.1 object-hash: 3.0.0 reflect-metadata: 0.2.2 - rxjs: 7.8.1 + rxjs: 7.8.2 tslib: 2.8.1 optionalDependencies: - '@nestjs/platform-socket.io': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.1))(@nestjs/websockets@11.1.14)(rxjs@7.8.1) + '@nestjs/platform-socket.io': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.14)(rxjs@7.8.2) '@next/env@16.0.0': {} @@ -12957,7 +12963,7 @@ snapshots: saxes: 6.0.0 symbol-tree: 3.2.4 tough-cookie: 6.0.1 - undici: 7.24.6 + undici: 7.24.7 w3c-xmlserializer: 5.0.0 webidl-conversions: 8.0.1 whatwg-mimetype: 5.0.0 @@ -14237,6 +14243,10 @@ snapshots: dependencies: tslib: 2.8.1 + rxjs@7.8.2: + dependencies: + tslib: 2.8.1 + safe-array-concat@1.1.3: dependencies: call-bind: 1.0.8 @@ -15042,7 +15052,7 @@ snapshots: undici-types@7.16.0: {} - undici@7.24.6: {} + undici@7.24.7: {} unicorn-magic@0.3.0: {} From 02adaa418bacd5a25c3c8660754a002ad6ac243c Mon Sep 17 00:00:00 2001 From: MaxymBeregovoi <108676512+maksberegovoi@users.noreply.github.com> Date: Thu, 2 Apr 2026 11:37:33 +0300 Subject: [PATCH 2/4] feat: update TransformResponseInterceptor - Add handling 204 status code - Change handling result if result is null --- .../interceptors/transform-response.interceptor.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/backend/src/shared/interceptors/transform-response.interceptor.ts b/apps/backend/src/shared/interceptors/transform-response.interceptor.ts index f5186ef..2b85881 100644 --- a/apps/backend/src/shared/interceptors/transform-response.interceptor.ts +++ b/apps/backend/src/shared/interceptors/transform-response.interceptor.ts @@ -1,14 +1,21 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; import { map, Observable } from 'rxjs'; import { ApiResponse } from '../dto'; +import { FastifyReply } from 'fastify'; @Injectable() export class TransformResponseInterceptor implements NestInterceptor> { intercept(context: ExecutionContext, next: CallHandler): Observable> { + const ctx = context.switchToHttp(); + const response = ctx.getResponse(); + return next.handle().pipe( map((result) => { + if (response.statusCode === 204) { + return undefined; + } if (result == null) { - return { data: null }; + return {}; } if (result.data) { return result; From 8e187d161c8e8109409d0c818e909d3197d37317 Mon Sep 17 00:00:00 2001 From: MaxymBeregovoi <108676512+maksberegovoi@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:14:21 +0300 Subject: [PATCH 3/4] feat: update CORS configuration - Add `corsConfig` to centralize CORS setup - Update `.env` and environment validation to support `CORS_ALLOWED_ORIGINS` - Replace hardcoded CORS options with dynamic configuration in `main.ts` - Update CI workflow to include `CORS_ALLOWED_ORIGINS` for tests --- .github/workflows/CI-workflow.yml | 1 + apps/backend/.env.example | 3 ++- apps/backend/.env.test | 3 ++- apps/backend/src/config/cors.config.ts | 19 +++++++++++++++++++ apps/backend/src/env.ts | 12 ++++++++++++ apps/backend/src/main.ts | 10 ++-------- 6 files changed, 38 insertions(+), 10 deletions(-) create mode 100644 apps/backend/src/config/cors.config.ts diff --git a/.github/workflows/CI-workflow.yml b/.github/workflows/CI-workflow.yml index 3ec013d..fa1cb13 100644 --- a/.github/workflows/CI-workflow.yml +++ b/.github/workflows/CI-workflow.yml @@ -76,6 +76,7 @@ jobs: JWT_SECRET: test JWT_EXPIRES_IN: 7d NODE_ENV: test + CORS_ALLOWED_ORIGINS: http://localhost:3000 steps: - name: Checkout diff --git a/apps/backend/.env.example b/apps/backend/.env.example index 10e15c1..651c453 100644 --- a/apps/backend/.env.example +++ b/apps/backend/.env.example @@ -2,4 +2,5 @@ PORT=3005 DATABASE_URL="postgresql://postgres:root@localhost:5432/task-tracker" JWT_SECRET=jwt-secret JWT_EXPIRES_IN=7d -NODE_ENV=dev \ No newline at end of file +NODE_ENV=dev +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,https://my-jira-clone.com \ No newline at end of file diff --git a/apps/backend/.env.test b/apps/backend/.env.test index 64b22d2..0967e51 100644 --- a/apps/backend/.env.test +++ b/apps/backend/.env.test @@ -2,4 +2,5 @@ PORT=3001 DATABASE_URL="postgresql://test:test@localhost:5432/test" JWT_SECRET=test-secret JWT_EXPIRES_IN=7d -NODE_ENV=test \ No newline at end of file +NODE_ENV=test +CORS_ALLOWED_ORIGINS=http://localhost:3000 \ No newline at end of file diff --git a/apps/backend/src/config/cors.config.ts b/apps/backend/src/config/cors.config.ts new file mode 100644 index 0000000..aca96b2 --- /dev/null +++ b/apps/backend/src/config/cors.config.ts @@ -0,0 +1,19 @@ +import { FastifyCorsOptions } from '@fastify/cors'; +import { env } from '../env'; + +export const corsConfig: FastifyCorsOptions = { + origin: (origin, callback) => { + if (!origin) { + return callback(null, true); + } + + if (env.CORS_ALLOWED_ORIGINS.includes(origin)) { + return callback(null, true); + } + + callback(new Error(`CORS Policy: Origin ${origin} not allowed`), false); + }, + credentials: true, + methods: ['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Accept', 'Authorization'], +}; diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 2663430..a61df87 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -13,6 +13,18 @@ const envSchema = z.object({ JWT_SECRET: z.string(), JWT_EXPIRES_IN: z.custom(), NODE_ENV: z.enum(NodeEnv), + CORS_ALLOWED_ORIGINS: z + .string() + .min(1, "CORS_ALLOWED_ORIGINS can't be empty") + .transform((val) => val.split(',')) + .pipe( + z.array( + z.url({ error: 'Origin must be valid URL' }).refine((val) => { + const url = new URL(val); + return url.origin === val; + }, 'Invalid CORS origin') + ) + ), }); export const env = envSchema.parse(process.env); diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 461f0df..bca99a5 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -9,6 +9,7 @@ import fastifyCookie from '@fastify/cookie'; import { env } from './env'; import { TransformResponseInterceptor } from './shared/interceptors'; import { ApiResponse, ApiResponsePaginated } from './shared/dto'; +import { corsConfig } from './config/cors.config'; async function bootstrap() { const PORT = Number(env.PORT); @@ -28,14 +29,7 @@ async function bootstrap() { threshold: 1024, }); - await app - .getHttpAdapter() - .getInstance() - .register(fastifyCors, { - origin: '*', - methods: ['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Accept', 'Authorization'], - }); + await app.getHttpAdapter().getInstance().register(fastifyCors, corsConfig); app.useGlobalPipes( new ValidationPipe({ From 737f5d160e51895cbf46696c3d7b7e090309e32b Mon Sep 17 00:00:00 2001 From: MaxymBeregovoi <108676512+maksberegovoi@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:26:58 +0300 Subject: [PATCH 4/4] refactor: extract Swagger setup to dedicated config file - Move Swagger configuration logic from `main.ts` to `swagger.config.ts` for better modularity --- apps/backend/src/config/swagger.config.ts | 22 ++++++++++++++++++++++ apps/backend/src/main.ts | 18 ++---------------- 2 files changed, 24 insertions(+), 16 deletions(-) create mode 100644 apps/backend/src/config/swagger.config.ts diff --git a/apps/backend/src/config/swagger.config.ts b/apps/backend/src/config/swagger.config.ts new file mode 100644 index 0000000..e2b6d79 --- /dev/null +++ b/apps/backend/src/config/swagger.config.ts @@ -0,0 +1,22 @@ +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import { ApiResponse, ApiResponsePaginated } from '../shared/dto'; +import { INestApplication } from '@nestjs/common'; + +export function setupSwagger(app: INestApplication): void { + const config = new DocumentBuilder() + .setTitle('Task Tracker API') + .setDescription('API documentation for Task Tracker') + .setVersion('1.0') + .addCookieAuth('access_token', { + type: 'apiKey', + in: 'cookie', + description: 'JWT token in HttpOnly cookie', + }) + .build(); + + const document = SwaggerModule.createDocument(app, config, { + extraModels: [ApiResponse, ApiResponsePaginated], + }); + + SwaggerModule.setup('doc', app, document); +} diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index bca99a5..2b5322c 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -1,6 +1,5 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; import { ValidationPipe } from '@nestjs/common'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import fastifyCompress from '@fastify/compress'; @@ -8,8 +7,8 @@ import fastifyCors from '@fastify/cors'; import fastifyCookie from '@fastify/cookie'; import { env } from './env'; import { TransformResponseInterceptor } from './shared/interceptors'; -import { ApiResponse, ApiResponsePaginated } from './shared/dto'; import { corsConfig } from './config/cors.config'; +import { setupSwagger } from './config/swagger.config'; async function bootstrap() { const PORT = Number(env.PORT); @@ -42,20 +41,7 @@ async function bootstrap() { }); app.useGlobalInterceptors(new TransformResponseInterceptor()); - const config = new DocumentBuilder() - .setTitle('Task Tracker API') - .setDescription('API documentation for Task Tracker') - .setVersion('1.0') - .addCookieAuth('access_token', { - type: 'apiKey', - in: 'cookie', - description: 'JWT token in HttpOnly cookie', - }) - .build(); - const document = SwaggerModule.createDocument(app, config, { - extraModels: [ApiResponse, ApiResponsePaginated], - }); - SwaggerModule.setup('doc', app, document); + setupSwagger(app); await app.listen(PORT, '0.0.0.0', () => console.log(`\x1b[34mServer started on port = ${PORT}\x1b[0m`)