From dbba45453b99a56d16c77669f14fea4169eb9937 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde Date: Wed, 20 May 2026 15:36:06 +0200 Subject: [PATCH 01/21] feat(api/v2): :sparkles: Implement GET /applications/:id --- apps/api-v2/.env.example | 1 + .../applications/applications.controller.ts | 17 ++++++++++++++++- .../applications/applications.service.ts | 19 ++++++++++++++++++- 3 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 apps/api-v2/.env.example diff --git a/apps/api-v2/.env.example b/apps/api-v2/.env.example new file mode 100644 index 00000000..849fd841 --- /dev/null +++ b/apps/api-v2/.env.example @@ -0,0 +1 @@ +JWT_SECRET=topsecret \ No newline at end of file diff --git a/apps/api-v2/src/sections/applications/applications.controller.ts b/apps/api-v2/src/sections/applications/applications.controller.ts index ad2a4993..893fe5ec 100644 --- a/apps/api-v2/src/sections/applications/applications.controller.ts +++ b/apps/api-v2/src/sections/applications/applications.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req } from "@nestjs/common"; +import { Body, Controller, Get, Post, Req, Param } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; import { ApplicationStatus } from "@repo/db"; import { Request } from "express"; @@ -112,4 +112,19 @@ export class ApplicationsController { req.token.id, ); } + + @Get("/:id") + @ApiBearerAuth() + @ApiOperation({ + summary: "Get Application by ID", + description: "Returns the application with the specified ID.", + }) + @ApiDefaultResponse(ApplicationDto, { description: "Success" }) + @ApiErrorResponse({ status: 401, description: "Unauthorized" }) + @ApiErrorResponse({ status: 404, description: "Application not found" }) + async getApplicationById( + @Param("id") id: string, + ) : ControllerResponse { + return await this.applicationsService.findById(id); + } } diff --git a/apps/api-v2/src/sections/applications/applications.service.ts b/apps/api-v2/src/sections/applications/applications.service.ts index a196894b..835ba1b6 100644 --- a/apps/api-v2/src/sections/applications/applications.service.ts +++ b/apps/api-v2/src/sections/applications/applications.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "src/common/db/prisma.service"; import { FilterParams } from "src/common/decorators/filter.decorator"; import { PaginationParams } from "src/common/decorators/pagination.decorator"; @@ -96,4 +96,21 @@ export class ApplicationsService { data: applicationData, }); } + + /** + * Finds an application by its ID. + * @param id - The ID of the application to find. + * @returns The application with the specified ID, or null if not found. + */ + async findById(id: string) { + const application = await this.prisma.application.findUnique({ + where: { id }, + }); + + if (!application) { + throw new NotFoundException("Application not found"); + } + + return application; + } } From 70d10f412181f05898accdd00723ea4fd245d9ae Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde Date: Wed, 20 May 2026 17:50:45 +0200 Subject: [PATCH 02/21] feat(api/v2): :sparkles: Implement PUT /applications/:id --- .../applications/applications.controller.ts | 20 +++++- .../applications/applications.service.ts | 31 +++++++++ .../dto/review.application.dto.ts | 66 +++++++++++++++++++ 3 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 apps/api-v2/src/sections/applications/dto/review.application.dto.ts diff --git a/apps/api-v2/src/sections/applications/applications.controller.ts b/apps/api-v2/src/sections/applications/applications.controller.ts index 893fe5ec..c9c2a4e9 100644 --- a/apps/api-v2/src/sections/applications/applications.controller.ts +++ b/apps/api-v2/src/sections/applications/applications.controller.ts @@ -1,4 +1,4 @@ -import { Body, Controller, Get, Post, Req, Param } from "@nestjs/common"; +import { Body, Controller, Get, Post, Req, Param, Put } from "@nestjs/common"; import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; import { ApplicationStatus } from "@repo/db"; import { Request } from "express"; @@ -23,6 +23,7 @@ import { ControllerResponse, PaginatedControllerResponse } from "src/typings"; import { ApplicationsService } from "./applications.service"; import { ApplicationDto } from "./dto/application.dto"; import { CreateApplicationDto } from "./dto/create.application.dto"; +import { ReviewApplicationDto } from "./dto/review.application.dto"; @Controller("applications") export class ApplicationsController { @@ -127,4 +128,21 @@ export class ApplicationsController { ) : ControllerResponse { return await this.applicationsService.findById(id); } + + @Put('/:id') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Review Application', + description: 'Review and update an application (set status, reason, claim, etc).', + }) + @ApiDefaultResponse(ApplicationDto, { description: 'Success' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 404, description: 'Application not found' }) + async reviewApplication( + @Param('id') id: string, + @Body() reviewApplicationDto: ReviewApplicationDto, + ): ControllerResponse { + return await this.applicationsService.review(id, reviewApplicationDto); + } } diff --git a/apps/api-v2/src/sections/applications/applications.service.ts b/apps/api-v2/src/sections/applications/applications.service.ts index 835ba1b6..b13bc40c 100644 --- a/apps/api-v2/src/sections/applications/applications.service.ts +++ b/apps/api-v2/src/sections/applications/applications.service.ts @@ -7,6 +7,7 @@ import { CreateApplicationDto } from "./dto/create.application.dto"; import { ApplicationDto } from "./dto/application.dto"; import { randomUUID } from "crypto"; import { ApplicationStatus } from "@repo/db"; +import { ReviewApplicationDto } from "./dto/review.application.dto"; @Injectable() export class ApplicationsService { @@ -113,4 +114,34 @@ export class ApplicationsService { return application; } + + /** + * Reviews an application by updating its status, reviewer, and other relevant fields. + * @param id The ID of the application to review. + * @param reviewApplicationDto The data transfer object containing the review details (status, reviewerId, reason, etc.). + * @returns The updated application. + */ + async review(id: string, reviewApplicationDto: ReviewApplicationDto) { + // TODO inject the userService to check if the reviewer exists (if reviewerId is provided) + const reviewerId = reviewApplicationDto.reviewerId ?? null; + + const status = reviewApplicationDto.status ?? ApplicationStatus.REVIEWING; + + const reviewedAt = + status !== ApplicationStatus.REVIEWING ? reviewApplicationDto.reviewedAt ?? new Date().toISOString() : null; + + const data: any = { + reviewerId, + status, + reviewedAt, + reason: reviewApplicationDto.reason ?? null, + claimId: reviewApplicationDto.claimId ?? null, + trial: reviewApplicationDto.trial ?? false, + }; + + return await this.prisma.application.update({ + where: { id }, + data, + }); + } } diff --git a/apps/api-v2/src/sections/applications/dto/review.application.dto.ts b/apps/api-v2/src/sections/applications/dto/review.application.dto.ts new file mode 100644 index 00000000..27858ebd --- /dev/null +++ b/apps/api-v2/src/sections/applications/dto/review.application.dto.ts @@ -0,0 +1,66 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApplicationStatus } from "@repo/db"; +import { + IsBoolean, + IsEnum, + IsISO8601, + IsOptional, + IsString, + IsUUID, +} from "class-validator"; + +export class ReviewApplicationDto { + + @ApiPropertyOptional({ + example: "00000000-0000-0000-0000-000000000000", + nullable: true, + description: "The ID of the reviewer who handled the application, if any.", + }) + @IsOptional() + @IsUUID() + reviewerId?: string | null; + + @ApiPropertyOptional({ + example: ApplicationStatus.ACCEPTED, + enum: ApplicationStatus, + description: 'The new status of the application.', + }) + @IsOptional() + @IsEnum(ApplicationStatus) + status?: ApplicationStatus; + + @ApiPropertyOptional({ + example: "2025-04-19T16:45:18.767Z", + nullable: true, + description: 'The time the application was reviewed.', + }) + @IsOptional() + @IsISO8601() + reviewedAt?: string | null; + + @ApiPropertyOptional({ + example: "User failed interview stage", + nullable: true, + description: 'The reason for the application decision, if any.', + }) + @IsOptional() + @IsString() + reason?: string | null; + + @ApiPropertyOptional({ + example: "00000000-0000-0000-0000-000000000000", + nullable: true, + description: 'The ID of the claim associated with the application, if any.', + }) + @IsOptional() + @IsUUID() + claimId?: string | null; + + @ApiPropertyOptional({ + example: false, + description: 'Indicates whether the application is a trial application.', + }) + @IsOptional() + @IsBoolean() + trial?: boolean; +} From 84107e7fec6df0093234129a7d2c975c4b929fff Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde Date: Wed, 20 May 2026 19:43:07 +0200 Subject: [PATCH 03/21] fix(api/v2): :bug: Fix ErrorResponseDto not being registered in swagger --- apps/api-v2/src/common/decorators/api-response.decorator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/api-v2/src/common/decorators/api-response.decorator.ts b/apps/api-v2/src/common/decorators/api-response.decorator.ts index 2949c56b..14f29045 100644 --- a/apps/api-v2/src/common/decorators/api-response.decorator.ts +++ b/apps/api-v2/src/common/decorators/api-response.decorator.ts @@ -87,6 +87,7 @@ export function ApiErrorResponse({ description = "Error: Internal Server Error", }: { status?: number; description?: string } = {}) { return applyDecorators( + ApiExtraModels(ErrorResponseDto), ApiResponse({ status, description, From 659133d48f519bb9ddca22c6b9d4c17c574da6f6 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde Date: Wed, 20 May 2026 19:44:24 +0200 Subject: [PATCH 04/21] feat(api/v2): :sparkles: Implement GET /applications/questions --- apps/api-v2/src/app.module.ts | 2 + .../application-questions.controller.ts | 63 +++++++++++++++ .../questions/application-questions.module.ts | 10 +++ .../application-questions.service.ts | 50 ++++++++++++ .../questions/dto/application-question.dto.ts | 80 +++++++++++++++++++ 5 files changed, 205 insertions(+) create mode 100644 apps/api-v2/src/sections/applications/questions/application-questions.controller.ts create mode 100644 apps/api-v2/src/sections/applications/questions/application-questions.module.ts create mode 100644 apps/api-v2/src/sections/applications/questions/application-questions.service.ts create mode 100644 apps/api-v2/src/sections/applications/questions/dto/application-question.dto.ts diff --git a/apps/api-v2/src/app.module.ts b/apps/api-v2/src/app.module.ts index 5154abaa..4a4d2040 100644 --- a/apps/api-v2/src/app.module.ts +++ b/apps/api-v2/src/app.module.ts @@ -8,9 +8,11 @@ import { AuthModule } from "./sections/auth/auth.module"; import { StatusModule } from "./sections/status/status.module"; import { UtilityModule } from "./sections/utility/utility.module"; import { ClaimsModule } from "./sections/claims/claims.module"; +import { ApplicationQuestionsModule } from "./sections/applications/questions/application-questions.module"; @Module({ imports: [ + ApplicationQuestionsModule, ApplicationsModule, AuthModule, ClaimsModule, diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts b/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts new file mode 100644 index 00000000..0d8f0edb --- /dev/null +++ b/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts @@ -0,0 +1,63 @@ +import { Controller, Get, Req } from "@nestjs/common"; +import { ApplicationQuestionsService } from "./application-questions.service"; +import { PaginatedControllerResponse } from "src/typings"; +import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; +import { Request } from "express"; +import { Filter, FilterParams } from "src/common/decorators/filter.decorator"; +import { Pagination, PaginationParams } from "src/common/decorators/pagination.decorator"; +import { Sorting, SortingParams } from "src/common/decorators/sorting.decorator"; +import { Sortable } from "src/common/decorators/sortable.decorator"; +import { Paginated } from "src/common/decorators/paginated.decorator"; +import { Filtered } from "src/common/decorators/filtered.decorator"; +import { ApiErrorResponse, ApiPaginatedResponseDto } from "src/common/decorators/api-response.decorator"; +import { ApplicationQuestionDto } from "./dto/application-question.dto"; + +@Controller("applications/questions") +export class ApplicationQuestionsController { + constructor(private readonly applicationQuestionsService: ApplicationQuestionsService) {} + + /** + * Returns all application questions of the currently authenticated team. + */ + @Get("/") + @ApiBearerAuth() + @Sortable({ + defaultSortBy: "title", + allowedFields: ["title", "id", "subtitle", "placeholder", "required", "sort", "type", "icon", "trial"], + defaultOrder: "asc", + }) + @Paginated() + @ApiOperation({ + summary: "Get All Application Questions", + description: + "Returns all application questions of the currently authenticated team.", + }) + @Filtered({ + fields: [ + { name: "title", required: false, type: String }, + { name: "subtitle", required: false, type: String }, + { name: "placeholder", required: false, type: String }, + { name: "required", required: false, type: Boolean }, + { name: "sort", required: false, type: Number }, + { name: "type", required: false, type: String }, + { name: "icon", required: false, type: String }, + { name: "trial", required: false, type: Boolean }, + ], + }) + @ApiPaginatedResponseDto(ApplicationQuestionDto, { description: "Success" }) + @ApiErrorResponse({ status: 401, description: "Unauthorized" }) + async getApplicationQuestions( + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + @Req() req: Request, + ): PaginatedControllerResponse { + return await this.applicationQuestionsService.findAll( + pagination, + sorting.sortBy, + sorting.order, + filter.filter, + req.token.id, + ); + } +} \ No newline at end of file diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.module.ts b/apps/api-v2/src/sections/applications/questions/application-questions.module.ts new file mode 100644 index 00000000..e40e7130 --- /dev/null +++ b/apps/api-v2/src/sections/applications/questions/application-questions.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { PrismaService } from "src/common/db/prisma.service"; +import { ApplicationQuestionsController } from "./application-questions.controller"; +import { ApplicationQuestionsService } from "./application-questions.service"; + +@Module({ + controllers: [ApplicationQuestionsController], + providers: [ApplicationQuestionsService, PrismaService], +}) +export class ApplicationQuestionsModule {} diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.service.ts b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts new file mode 100644 index 00000000..37c25393 --- /dev/null +++ b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts @@ -0,0 +1,50 @@ +import { Injectable } from "@nestjs/common"; +import { PrismaService } from "src/common/db/prisma.service"; +import { FilterParams } from "src/common/decorators/filter.decorator"; +import { PaginationParams } from "src/common/decorators/pagination.decorator"; +import { SortingParams } from "src/common/decorators/sorting.decorator"; + +@Injectable() +export class ApplicationQuestionsService { + constructor(private readonly prisma: PrismaService) {} + + async findAll( + pagination: PaginationParams, + sortBy?: SortingParams["sortBy"], + order?: SortingParams["order"], + filter?: FilterParams["filter"], + buildTeamId?: string, + ) { + const sortField = sortBy || "title"; + const sortOrder = order === "desc" ? "desc" : "asc"; + + const take = Math.max(Number(pagination.limit) || 20, 1); + const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; + + const combinedFilter = { + ...filter, + ...(buildTeamId ? { buildTeamId } : {}), + }; + + + const [questions, count] = await Promise.all([ + this.prisma.applicationQuestion.findMany({ + where: combinedFilter, + orderBy: { [sortField]: sortOrder }, + skip, + take, + }), + this.prisma.applicationQuestion.count( { where: combinedFilter } ), + ]); + + return { + data: questions, + meta: { + page: pagination.page, + perPage: pagination.limit, + totalItems: count, + totalPages: Math.ceil(count / pagination.limit), + } + } + } + } \ No newline at end of file diff --git a/apps/api-v2/src/sections/applications/questions/dto/application-question.dto.ts b/apps/api-v2/src/sections/applications/questions/dto/application-question.dto.ts new file mode 100644 index 00000000..0d9b4810 --- /dev/null +++ b/apps/api-v2/src/sections/applications/questions/dto/application-question.dto.ts @@ -0,0 +1,80 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApplicationQuestionType } from "@repo/db"; +import { IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID } from "class-validator"; + +export class ApplicationQuestionDto { + @ApiProperty({ + example: "00000000-0000-0000-0000-000000000000", + description: "The unique ID of the application question.", + }) + @IsUUID() + id: string; + + @ApiProperty({ + example: "What is your experience?", + }) + @IsString() + title: string; + + @ApiProperty({ + example: "Tell us about your past projects and roles.", + }) + @IsString() + subtitle: string; + + @ApiPropertyOptional({ + example: "", + default: "", + }) + @IsOptional() + @IsString() + placeholder?: string; + + @ApiProperty({ + example: true, + default: true, + }) + @IsBoolean() + required: boolean; + + @ApiProperty({ + enum: ApplicationQuestionType, + example: ApplicationQuestionType.TEXT, + }) + @IsEnum(ApplicationQuestionType) + type: ApplicationQuestionType; + + @ApiProperty({ + example: "briefcase", + }) + @IsString() + icon: string; + + @ApiProperty({ + example: {}, + description: "Extra dynamic configuration based on the question type.", + }) + @IsObject() + additionalData: Record; + + @ApiProperty({ + example: "00000000-0000-0000-0000-000000000000", + description: "The unique ID of the build team this question belongs to.", + }) + @IsUUID() + buildTeamId: string; + + @ApiProperty({ + example: 1, + }) + @IsInt() + sort: number; + + @ApiPropertyOptional({ + example: false, + default: false, + }) + @IsOptional() + @IsBoolean() + trial?: boolean; +} \ No newline at end of file From be6737b1b09d91cd9c47379d9e5eb170536dc41d Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Wed, 20 May 2026 21:17:29 +0200 Subject: [PATCH 05/21] test(api/v2): :white_check_mark: Add UtilityController tests (#117) --- apps/api-v2/package.json | 18 +- .../utility/utility.controller.spec.ts | 79 ++++++ apps/api-v2/tsconfig.spec.json | 8 + yarn.lock | 246 ++++++++++++++++-- 4 files changed, 321 insertions(+), 30 deletions(-) create mode 100644 apps/api-v2/test/sections/utility/utility.controller.spec.ts create mode 100644 apps/api-v2/tsconfig.spec.json diff --git a/apps/api-v2/package.json b/apps/api-v2/package.json index 34fc1535..60177547 100644 --- a/apps/api-v2/package.json +++ b/apps/api-v2/package.json @@ -38,7 +38,7 @@ "@eslint/js": "^9.18.0", "@nestjs/cli": "^11.0.0", "@nestjs/schematics": "^11.0.0", - "@nestjs/testing": "^11.0.1", + "@nestjs/testing": "^11.1.21", "@repo/prettier-config": "*", "@repo/typescript-config": "*", "@swc/cli": "^0.6.0", @@ -69,13 +69,23 @@ "json", "ts" ], - "rootDir": "src", + "rootDir": "test", "testRegex": ".*\\.spec\\.ts$", "transform": { - "^.+\\.(t|j)s$": "ts-jest" + "^.+\\.(t|j)s$": [ + "ts-jest", + { + "tsconfig": "/../tsconfig.spec.json" + } + ] + }, + "moduleNameMapper": { + "^src/(.*)$": "/../src/$1" }, "collectCoverageFrom": [ - "**/*.(t|j)s" + "../src/**/*.{ts,js}", + "!../src/**/*.spec.{ts,js}", + "!../src/**/*.test.{ts,js}" ], "coverageDirectory": "../coverage", "testEnvironment": "node" diff --git a/apps/api-v2/test/sections/utility/utility.controller.spec.ts b/apps/api-v2/test/sections/utility/utility.controller.spec.ts new file mode 100644 index 00000000..7efb6a93 --- /dev/null +++ b/apps/api-v2/test/sections/utility/utility.controller.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { GoneException } from '@nestjs/common'; +import { UtilityController } from 'src/sections/utility/utility.controller'; + +describe('UtilityController', () => { + let utilityController: UtilityController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [UtilityController], + }).compile(); + + utilityController = module.get(UtilityController); + }); + + describe('getHealth', () => { + it('should return health status', async () => { + const result = await utilityController.getHealth(); + + expect(result.status).toBe('ok'); + expect(result.timestamp).toBeDefined(); + + // ensure timestamp is valid ISO string + expect(new Date(result.timestamp).toISOString()).toBe(result.timestamp); + }); + }); + + describe('getVersion', () => { + it('should return version info', async () => { + const result = await utilityController.getVersion(); + + expect(result).toEqual( + expect.objectContaining({ + apiVersion: 'v2', + }), + ); + + expect(typeof result.version).toBe('string'); + expect(typeof result.name).toBe('string'); + }); + + it('should fallback to "unknown" when env vars are missing', async () => { + const originalVersion = process.env.npm_package_version; + const originalName = process.env.npm_package_name; + + try { + delete process.env.npm_package_version; + delete process.env.npm_package_name; + + const result = await utilityController.getVersion(); + + expect(result.version).toBe('unknown'); + expect(result.name).toBe('unknown'); + } finally { + if (originalVersion === undefined) { + delete process.env.npm_package_version; + } else { + process.env.npm_package_version = originalVersion; + } + + if (originalName === undefined) { + delete process.env.npm_package_name; + } else { + process.env.npm_package_name = originalName; + } + } + }); + }); + + describe('getOldRoutes', () => { + it('should throw GoneException', () => { + expect(() => utilityController.getOldRoutes()).toThrow(GoneException); + + expect(() => utilityController.getOldRoutes()).toThrow( + 'Deprecated API endpoint. Please use the new API version 2', + ); + }); + }); +}); diff --git a/apps/api-v2/tsconfig.spec.json b/apps/api-v2/tsconfig.spec.json new file mode 100644 index 00000000..411fd28f --- /dev/null +++ b/apps/api-v2/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["jest", "node"], + "isolatedModules": true + }, + "include": ["src", "test", "**/*.spec.ts"] +} diff --git a/yarn.lock b/yarn.lock index c9e42313..5db1a705 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2250,6 +2250,13 @@ __metadata: languageName: node linkType: hard +"@jest/diff-sequences@npm:30.4.0": + version: 30.4.0 + resolution: "@jest/diff-sequences@npm:30.4.0" + checksum: 10c0/b4358b1b885098b905cb777f58788ddd45f90c4ebc3ce2c04fb1d4c9516f35ac2d9daef8263cd21c537bd7a52ab320f03e4ba9521677959ae20e3d405356b420 + languageName: node + linkType: hard + "@jest/environment@npm:^29.7.0": version: 29.7.0 resolution: "@jest/environment@npm:29.7.0" @@ -2262,6 +2269,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/expect-utils@npm:30.4.1" + dependencies: + "@jest/get-type": "npm:30.1.0" + checksum: 10c0/6dea9e11ebcc7be68fea5950ae5a1b7ff9fd1490101ee8af0aede336b9934ab24a28bcafe2f1171dac0f95982406386c609ca2659b9132e1a9d419e8d69b9cd4 + languageName: node + linkType: hard + "@jest/expect-utils@npm:^29.7.0": version: 29.7.0 resolution: "@jest/expect-utils@npm:29.7.0" @@ -2295,6 +2311,13 @@ __metadata: languageName: node linkType: hard +"@jest/get-type@npm:30.1.0": + version: 30.1.0 + resolution: "@jest/get-type@npm:30.1.0" + checksum: 10c0/3e65fd5015f551c51ec68fca31bbd25b466be0e8ee8075d9610fa1c686ea1e70a942a0effc7b10f4ea9a338c24337e1ad97ff69d3ebacc4681b7e3e80d1b24ac + languageName: node + linkType: hard + "@jest/globals@npm:^29.7.0": version: 29.7.0 resolution: "@jest/globals@npm:29.7.0" @@ -2307,6 +2330,16 @@ __metadata: languageName: node linkType: hard +"@jest/pattern@npm:30.4.0": + version: 30.4.0 + resolution: "@jest/pattern@npm:30.4.0" + dependencies: + "@types/node": "npm:*" + jest-regex-util: "npm:30.4.0" + checksum: 10c0/05bc0799f84f3750bbbff0f9a546979efd0dbcee86c1be98b9e2811a68885809ec7b5cca39b8dda1497cb7cf17b7be936019fba8dfbcd9c53b181e03e67f4f82 + languageName: node + linkType: hard + "@jest/reporters@npm:^29.7.0": version: 29.7.0 resolution: "@jest/reporters@npm:29.7.0" @@ -2344,6 +2377,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/schemas@npm:30.4.1" + dependencies: + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10c0/96f388ebfc1974457fcbde2ad36c40a0b549cba3f624fe8d9d6e5903a152dc75e4043f4ac9ac7668622f2ecb0f9a4dcb9a38edf3bc0d52b82045b2bb2b69b72a + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -2411,6 +2453,21 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/types@npm:30.4.1" + dependencies: + "@jest/pattern": "npm:30.4.0" + "@jest/schemas": "npm:30.4.1" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + "@types/istanbul-reports": "npm:^3.0.4" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.33" + chalk: "npm:^4.1.2" + checksum: 10c0/4c79f6dbdb1c7eaab5da255fc696c7cae744759d4020e42da8aa63b37fe55ce594be73075fe1ee5407dd59d7e47975be9f674bfc81e91bae2c89c62d27ba55a1 + languageName: node + linkType: hard + "@jest/types@npm:^29.6.3": version: 29.6.3 resolution: "@jest/types@npm:29.6.3" @@ -3332,9 +3389,9 @@ __metadata: languageName: node linkType: hard -"@nestjs/testing@npm:^11.0.1": - version: 11.1.3 - resolution: "@nestjs/testing@npm:11.1.3" +"@nestjs/testing@npm:^11.1.21": + version: 11.1.21 + resolution: "@nestjs/testing@npm:11.1.21" dependencies: tslib: "npm:2.8.1" peerDependencies: @@ -3347,7 +3404,7 @@ __metadata: optional: true "@nestjs/platform-express": optional: true - checksum: 10c0/0de932c2eb69ad07927bf1619a422a5ce6f5f01af16d37589db5455e43477bc5415c6a72d047c6dfca5c0d579ae63387409a75e15559546fddcc358b9fcd3d7d + checksum: 10c0/8a3cb16d78fdd5162f1f4dc7e2950e6dd0d572921253e972187dd04ef857df77aa88aca0c2ae2677fcc729e4ffad4f3521151805d215b810c33ea2fecff794c9 languageName: node linkType: hard @@ -3696,6 +3753,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.34.0": + version: 0.34.49 + resolution: "@sinclair/typebox@npm:0.34.49" + checksum: 10c0/16b7d87f039a49b68c10bb4cdcae2ce5242b2472228851fd6483731616aba4ef977690aa517b230a8d20da8185bb416eb34e326f30568b3963c1cf26b05d1ad8 + languageName: node + linkType: hard + "@sindresorhus/is@npm:^5.2.0": version: 5.6.0 resolution: "@sindresorhus/is@npm:5.6.0" @@ -8347,7 +8411,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 @@ -8363,7 +8427,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-reports@npm:^3.0.0": +"@types/istanbul-reports@npm:^3.0.0, @types/istanbul-reports@npm:^3.0.4": version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: @@ -8372,13 +8436,13 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.5.14": - version: 29.5.14 - resolution: "@types/jest@npm:29.5.14" +"@types/jest@npm:^30.0.0": + version: 30.0.0 + resolution: "@types/jest@npm:30.0.0" dependencies: - expect: "npm:^29.0.0" - pretty-format: "npm:^29.0.0" - checksum: 10c0/18e0712d818890db8a8dab3d91e9ea9f7f19e3f83c2e50b312f557017dc81466207a71f3ed79cf4428e813ba939954fa26ffa0a9a7f153181ba174581b1c2aed + expect: "npm:^30.0.0" + pretty-format: "npm:^30.0.0" + checksum: 10c0/20c6ce574154bc16f8dd6a97afacca4b8c4921a819496a3970382031c509ebe87a1b37b152a1b8475089b82d8ca951a9e95beb4b9bf78fbf579b1536f0b65969 languageName: node linkType: hard @@ -8722,7 +8786,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0": +"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c @@ -8809,6 +8873,15 @@ __metadata: languageName: node linkType: hard +"@types/yargs@npm:^17.0.33": + version: 17.0.35 + resolution: "@types/yargs@npm:17.0.35" + dependencies: + "@types/yargs-parser": "npm:*" + checksum: 10c0/609557826a6b85e73ccf587923f6429850d6dc70e420b455bab4601b670bfadf684b09ae288bccedab042c48ba65f1666133cf375814204b544009f57d6eef63 + languageName: node + linkType: hard + "@types/yargs@npm:^17.0.8": version: 17.0.33 resolution: "@types/yargs@npm:17.0.33" @@ -9810,7 +9883,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df @@ -9856,14 +9929,14 @@ __metadata: "@nestjs/platform-express": "npm:^11.0.1" "@nestjs/schematics": "npm:^11.0.0" "@nestjs/swagger": "npm:^11.2.0" - "@nestjs/testing": "npm:^11.0.1" + "@nestjs/testing": "npm:^11.1.21" "@repo/db": "npm:*" "@repo/prettier-config": "npm:*" "@repo/typescript-config": "npm:*" "@swc/cli": "npm:^0.6.0" "@swc/core": "npm:^1.10.7" "@types/express": "npm:^5.0.0" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/node": "npm:^22.10.7" "@types/supertest": "npm:^6.0.2" axios: "npm:^1.13.2" @@ -10993,6 +11066,13 @@ __metadata: languageName: node linkType: hard +"ci-info@npm:^4.2.0": + version: 4.4.0 + resolution: "ci-info@npm:4.4.0" + checksum: 10c0/44156201545b8dde01aa8a09ee2fe9fc7a73b1bef9adbd4606c9f61c8caeeb73fb7a575c88b0443f7b4edb5ee45debaa59ed54ba5f99698339393ca01349eb3a + languageName: node + linkType: hard + "cjs-module-lexer@npm:^1.0.0": version: 1.4.3 resolution: "cjs-module-lexer@npm:1.4.3" @@ -13574,7 +13654,7 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.0.0, expect@npm:^29.7.0": +"expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" dependencies: @@ -13587,6 +13667,20 @@ __metadata: languageName: node linkType: hard +"expect@npm:^30.0.0": + version: 30.4.1 + resolution: "expect@npm:30.4.1" + dependencies: + "@jest/expect-utils": "npm:30.4.1" + "@jest/get-type": "npm:30.1.0" + jest-matcher-utils: "npm:30.4.1" + jest-message-util: "npm:30.4.1" + jest-mock: "npm:30.4.1" + jest-util: "npm:30.4.1" + checksum: 10c0/ad04fbdffac5a2bae186478938a60f737e3aac823db9a80c87f3f390f9f458bddcc454dc3a3997d715706747c6aff928923e6a71db3a221adb89a51cc1582e72 + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.2 resolution: "exponential-backoff@npm:3.1.2" @@ -16214,6 +16308,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:30.4.1": + version: 30.4.1 + resolution: "jest-diff@npm:30.4.1" + dependencies: + "@jest/diff-sequences": "npm:30.4.0" + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.4.1" + checksum: 10c0/787e11f0ea27e94815479d6c5415e4173da1e74bede34c1515b8515fc9d1fe053e2ad25a3c31f9998a7292c186a0e4d395ed82e0e149d57d7708ee6759b442e9 + languageName: node + linkType: hard + "jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -16302,6 +16408,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:30.4.1": + version: 30.4.1 + resolution: "jest-matcher-utils@npm:30.4.1" + dependencies: + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.4.1" + pretty-format: "npm:30.4.1" + checksum: 10c0/ddbb0c7075def27ba30160883c327cb3fd13f561f5789d00a1edca1b48b0651f8ea23a1c51bcfcb6413a68c47d658bcf47a34701b8a39ce135dd28d87a3117af + languageName: node + linkType: hard + "jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -16314,6 +16432,24 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:30.4.1": + version: 30.4.1 + resolution: "jest-message-util@npm:30.4.1" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@jest/types": "npm:30.4.1" + "@types/stack-utils": "npm:^2.0.3" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + jest-util: "npm:30.4.1" + picomatch: "npm:^4.0.3" + pretty-format: "npm:30.4.1" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10c0/ae7427544e042bc1c14abf3c0dbe8b83d0dbec22a9a5efefaca5b8ccb6b9bf391abe732e6f2117ca995c6889bfe1be35c78cec75e5ea0a50e28cffe1ba6f9fdf + languageName: node + linkType: hard + "jest-message-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" @@ -16331,6 +16467,17 @@ __metadata: languageName: node linkType: hard +"jest-mock@npm:30.4.1": + version: 30.4.1 + resolution: "jest-mock@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + jest-util: "npm:30.4.1" + checksum: 10c0/5185a41255285c1634c5d85dda037afaaadfc12793b3293c9e253a30bb67449f8df968447f830abb9cf7a52e63694e6734680130e8085ce119056280890bf6fc + languageName: node + linkType: hard + "jest-mock@npm:^29.7.0": version: 29.7.0 resolution: "jest-mock@npm:29.7.0" @@ -16354,6 +16501,13 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:30.4.0": + version: 30.4.0 + resolution: "jest-regex-util@npm:30.4.0" + checksum: 10c0/fe7426f67b54d38bed8e9d6e6a099d63d72f41f5bf65b922d9d03fedcb55c614b45657207632f6ee22d0a59d8d11327891f258d23f68a58912fcdb0f7db48435 + languageName: node + linkType: hard + "jest-regex-util@npm:^29.6.3": version: 29.6.3 resolution: "jest-regex-util@npm:29.6.3" @@ -16475,6 +16629,20 @@ __metadata: languageName: node linkType: hard +"jest-util@npm:30.4.1": + version: 30.4.1 + resolution: "jest-util@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.3" + checksum: 10c0/3efe1f25e5a172d04c6af8612d82867ab603b7c1bd8cb89073ff834679b44eba178793cf3af162cf5e25be13aa736ebd23a7826683acc85bddc5873f305b1f6e + languageName: node + linkType: hard + "jest-util@npm:^29.0.0, jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" @@ -18846,6 +19014,13 @@ __metadata: languageName: node linkType: hard +"picomatch@npm:^4.0.3": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 + languageName: node + linkType: hard + "pidtree@npm:~0.6.0": version: 0.6.0 resolution: "pidtree@npm:0.6.0" @@ -19161,7 +19336,19 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": +"pretty-format@npm:30.4.1, pretty-format@npm:^30.0.0": + version: 30.4.1 + resolution: "pretty-format@npm:30.4.1" + dependencies: + "@jest/schemas": "npm:30.4.1" + ansi-styles: "npm:^5.2.0" + react-is-18: "npm:react-is@^18.3.1" + react-is-19: "npm:react-is@^19.2.5" + checksum: 10c0/c7e6633740cd2f6d382f188c00c8b4b3f2bee3cda16db6753471c6bb4b94f76531358d3a7793062a0fb00d72ebfb934e8ae1d4f5ced6bb34c8e7f60996f90076 + languageName: node + linkType: hard + +"pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" dependencies: @@ -19759,6 +19946,20 @@ __metadata: languageName: node linkType: hard +"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0, react-is@npm:^18.3.1": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 + languageName: node + linkType: hard + +"react-is-19@npm:react-is@^19.2.5": + version: 19.2.6 + resolution: "react-is@npm:19.2.6" + checksum: 10c0/263177f370fc156b279d22570dd6e922a0ad641a4a426a4cb70284b8003b00ef532d59f2beca1d22a1ca0b37f85f9077d7733ca5d344ebecd2942e9bc2a2a3c0 + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -19766,13 +19967,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0, react-is@npm:^18.3.1": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 - languageName: node - linkType: hard - "react-map-gl@npm:^7.0.16": version: 7.1.7 resolution: "react-map-gl@npm:7.1.7" @@ -21224,7 +21418,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3": +"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: From 177833b3077dfe6818742fd853bbcd897417e697 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde Date: Thu, 21 May 2026 20:09:56 +0200 Subject: [PATCH 06/21] feat(api/v2): :sparkles: Implement DEL /applications/questions/:id (and tests) --- .../application-questions.controller.ts | 139 ++++++++++-------- .../application-questions.service.ts | 90 ++++++------ .../application-questions.controller.spec.ts | 42 ++++++ .../application-questions.service.spec.ts | 37 +++++ 4 files changed, 207 insertions(+), 101 deletions(-) create mode 100644 apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts create mode 100644 apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts b/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts index 0d8f0edb..5d79c453 100644 --- a/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts +++ b/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts @@ -1,63 +1,82 @@ -import { Controller, Get, Req } from "@nestjs/common"; -import { ApplicationQuestionsService } from "./application-questions.service"; -import { PaginatedControllerResponse } from "src/typings"; -import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; -import { Request } from "express"; -import { Filter, FilterParams } from "src/common/decorators/filter.decorator"; -import { Pagination, PaginationParams } from "src/common/decorators/pagination.decorator"; -import { Sorting, SortingParams } from "src/common/decorators/sorting.decorator"; -import { Sortable } from "src/common/decorators/sortable.decorator"; -import { Paginated } from "src/common/decorators/paginated.decorator"; -import { Filtered } from "src/common/decorators/filtered.decorator"; -import { ApiErrorResponse, ApiPaginatedResponseDto } from "src/common/decorators/api-response.decorator"; -import { ApplicationQuestionDto } from "./dto/application-question.dto"; +import { Controller, Delete, Get, Param, Req } from '@nestjs/common'; +import { ApplicationQuestionsService } from './application-questions.service'; +import { ControllerResponse, PaginatedControllerResponse } from 'src/typings'; +import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Request } from 'express'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { Sorting, SortingParams } from 'src/common/decorators/sorting.decorator'; +import { Sortable } from 'src/common/decorators/sortable.decorator'; +import { Paginated } from 'src/common/decorators/paginated.decorator'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { + ApiDefaultResponse, + ApiErrorResponse, + ApiPaginatedResponseDto, +} from 'src/common/decorators/api-response.decorator'; +import { ApplicationQuestionDto } from './dto/application-question.dto'; -@Controller("applications/questions") +@Controller('applications/questions') export class ApplicationQuestionsController { - constructor(private readonly applicationQuestionsService: ApplicationQuestionsService) {} + constructor(private readonly applicationQuestionsService: ApplicationQuestionsService) {} - /** - * Returns all application questions of the currently authenticated team. - */ - @Get("/") - @ApiBearerAuth() - @Sortable({ - defaultSortBy: "title", - allowedFields: ["title", "id", "subtitle", "placeholder", "required", "sort", "type", "icon", "trial"], - defaultOrder: "asc", - }) - @Paginated() - @ApiOperation({ - summary: "Get All Application Questions", - description: - "Returns all application questions of the currently authenticated team.", - }) - @Filtered({ - fields: [ - { name: "title", required: false, type: String }, - { name: "subtitle", required: false, type: String }, - { name: "placeholder", required: false, type: String }, - { name: "required", required: false, type: Boolean }, - { name: "sort", required: false, type: Number }, - { name: "type", required: false, type: String }, - { name: "icon", required: false, type: String }, - { name: "trial", required: false, type: Boolean }, - ], - }) - @ApiPaginatedResponseDto(ApplicationQuestionDto, { description: "Success" }) - @ApiErrorResponse({ status: 401, description: "Unauthorized" }) - async getApplicationQuestions( - @Pagination() pagination: PaginationParams, - @Sorting() sorting: SortingParams, - @Filter() filter: FilterParams, - @Req() req: Request, - ): PaginatedControllerResponse { - return await this.applicationQuestionsService.findAll( - pagination, - sorting.sortBy, - sorting.order, - filter.filter, - req.token.id, - ); - } -} \ No newline at end of file + /** + * Returns all application questions of the currently authenticated team. + */ + @Get('/') + @ApiBearerAuth() + @Sortable({ + defaultSortBy: 'title', + allowedFields: ['title', 'id', 'subtitle', 'placeholder', 'required', 'sort', 'type', 'icon', 'trial'], + defaultOrder: 'asc', + }) + @Paginated() + @ApiOperation({ + summary: 'Get All Application Questions', + description: 'Returns all application questions of the currently authenticated team.', + }) + @Filtered({ + fields: [ + { name: 'title', required: false, type: String }, + { name: 'subtitle', required: false, type: String }, + { name: 'placeholder', required: false, type: String }, + { name: 'required', required: false, type: Boolean }, + { name: 'sort', required: false, type: Number }, + { name: 'type', required: false, type: String }, + { name: 'icon', required: false, type: String }, + { name: 'trial', required: false, type: Boolean }, + ], + }) + @ApiPaginatedResponseDto(ApplicationQuestionDto, { description: 'Success' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + async getApplicationQuestions( + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + @Req() req: Request, + ): PaginatedControllerResponse { + return await this.applicationQuestionsService.findAll( + pagination, + sorting.sortBy, + sorting.order, + filter.filter, + req.token.id, + ); + } + + /** + * Deletes the question with the given ID if it belongs to the currently authenticated team. + */ + @Delete(':id') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete Application Question', + description: 'Deletes the question with the given ID if it belongs to the currently authenticated team.', + }) + @ApiResponse({ status: 204, description: 'No Content' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Question not found' }) + async deleteApplicationQuestion(@Param('id') id: string, @Req() req: Request): ControllerResponse { + return await this.applicationQuestionsService.delete(id, req.token.id); + } +} diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.service.ts b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts index 37c25393..49fecb00 100644 --- a/apps/api-v2/src/sections/applications/questions/application-questions.service.ts +++ b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts @@ -1,50 +1,58 @@ -import { Injectable } from "@nestjs/common"; -import { PrismaService } from "src/common/db/prisma.service"; -import { FilterParams } from "src/common/decorators/filter.decorator"; -import { PaginationParams } from "src/common/decorators/pagination.decorator"; -import { SortingParams } from "src/common/decorators/sorting.decorator"; +import { Injectable } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { FilterParams } from 'src/common/decorators/filter.decorator'; +import { PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { SortingParams } from 'src/common/decorators/sorting.decorator'; @Injectable() export class ApplicationQuestionsService { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly prisma: PrismaService) {} - async findAll( - pagination: PaginationParams, - sortBy?: SortingParams["sortBy"], - order?: SortingParams["order"], - filter?: FilterParams["filter"], - buildTeamId?: string, - ) { - const sortField = sortBy || "title"; - const sortOrder = order === "desc" ? "desc" : "asc"; + async findAll( + pagination: PaginationParams, + sortBy?: SortingParams['sortBy'], + order?: SortingParams['order'], + filter?: FilterParams['filter'], + buildTeamId?: string, + ) { + const sortField = sortBy || 'title'; + const sortOrder = order === 'desc' ? 'desc' : 'asc'; - const take = Math.max(Number(pagination.limit) || 20, 1); - const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; + const take = Math.max(Number(pagination.limit) || 20, 1); + const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; - const combinedFilter = { - ...filter, - ...(buildTeamId ? { buildTeamId } : {}), - }; + const combinedFilter = { + ...filter, + ...(buildTeamId ? { buildTeamId } : {}), + }; + const [questions, count] = await Promise.all([ + this.prisma.applicationQuestion.findMany({ + where: combinedFilter, + orderBy: { [sortField]: sortOrder }, + skip, + take, + }), + this.prisma.applicationQuestion.count({ where: combinedFilter }), + ]); - const [questions, count] = await Promise.all([ - this.prisma.applicationQuestion.findMany({ - where: combinedFilter, - orderBy: { [sortField]: sortOrder }, - skip, - take, - }), - this.prisma.applicationQuestion.count( { where: combinedFilter } ), - ]); + return { + data: questions, + meta: { + page: pagination.page, + perPage: pagination.limit, + totalItems: count, + totalPages: Math.ceil(count / pagination.limit), + }, + }; + } - return { - data: questions, - meta: { - page: pagination.page, - perPage: pagination.limit, - totalItems: count, - totalPages: Math.ceil(count / pagination.limit), - } - } - } - } \ No newline at end of file + async delete(id: string, buildTeamId: string) { + return await this.prisma.applicationQuestion.deleteMany({ + where: { + id, + buildTeamId, + }, + }); + } +} diff --git a/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts b/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts new file mode 100644 index 00000000..c8ddcc4f --- /dev/null +++ b/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts @@ -0,0 +1,42 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Request } from 'express'; +import { ApplicationQuestionsController } from 'src/sections/applications/questions/application-questions.controller'; +import { ApplicationQuestionsService } from 'src/sections/applications/questions/application-questions.service'; + +describe('ApplicationQuestionsController', () => { + let applicationQuestionsController: ApplicationQuestionsController; + let applicationQuestionsService: { + delete: jest.Mock; + }; + + beforeEach(async () => { + applicationQuestionsService = { + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ApplicationQuestionsController], + providers: [ + { + provide: ApplicationQuestionsService, + useValue: applicationQuestionsService, + }, + ], + }).compile(); + + applicationQuestionsController = module.get(ApplicationQuestionsController); + }); + + describe('deleteApplicationQuestion', () => { + it('should delete the question for the authenticated team', async () => { + applicationQuestionsService.delete.mockResolvedValue(undefined); + + const req = { token: { id: 'team-123' } } as unknown as Request; + + const result = await applicationQuestionsController.deleteApplicationQuestion('question-1', req); + + expect(applicationQuestionsService.delete).toHaveBeenCalledWith('question-1', 'team-123'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts b/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts new file mode 100644 index 00000000..0ab2ffa0 --- /dev/null +++ b/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts @@ -0,0 +1,37 @@ +import { ApplicationQuestionsService } from 'src/sections/applications/questions/application-questions.service'; +import { PrismaService } from 'src/common/db/prisma.service'; + +describe('ApplicationQuestionsService', () => { + let applicationQuestionsService: ApplicationQuestionsService; + let prismaService: { + applicationQuestion: { + deleteMany: jest.Mock; + }; + }; + + beforeEach(() => { + prismaService = { + applicationQuestion: { + deleteMany: jest.fn(), + }, + }; + + applicationQuestionsService = new ApplicationQuestionsService(prismaService as unknown as PrismaService); + }); + + describe('delete', () => { + it('should delete the question for the given question and team ids', async () => { + prismaService.applicationQuestion.deleteMany.mockResolvedValue({ count: 1 }); + + const result = await applicationQuestionsService.delete('question-1', 'team-123'); + + expect(prismaService.applicationQuestion.deleteMany).toHaveBeenCalledWith({ + where: { + id: 'question-1', + buildTeamId: 'team-123', + }, + }); + expect(result).toEqual({ count: 1 }); + }); + }); +}); From 6a0e79ceedad595dc60cf75c07a01215cee1c551 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde Date: Sat, 20 Jun 2026 19:28:22 +0200 Subject: [PATCH 07/21] test(api/v2): :white_check_mark: Add tests for almost all existing api-v2 code --- apps/api-v2/jest.config.ts | 36 +++++ apps/api-v2/package.json | 27 ---- apps/api-v2/src/main.ts | 12 +- apps/api-v2/test/bootstrap/main.spec.ts | 96 +++++++++++ .../common/db/external/cachet.service.spec.ts | 46 ++++++ .../test/common/db/prisma.service.spec.ts | 12 ++ .../decorators/filter.decorator.spec.ts | 110 +++++++++++++ .../decorators/pagination.decorator.spec.ts | 72 +++++++++ .../decorators/sorting.decorator.spec.ts | 78 +++++++++ .../test/common/guards/auth.guard.spec.ts | 73 +++++++++ .../interceptors/error.interceptor.spec.ts | 72 +++++++++ .../interceptors/response.interceptor.spec.ts | 45 ++++++ .../applications.controller.spec.ts | 106 +++++++++++++ .../applications/applications.service.spec.ts | 150 ++++++++++++++++++ .../application-questions.controller.spec.ts | 35 ++++ .../sections/auth/auth.controller.spec.ts | 51 ++++++ .../test/sections/auth/auth.service.spec.ts | 79 +++++++++ .../sections/claims/claims.controller.spec.ts | 82 ++++++++++ .../sections/claims/claims.service.spec.ts | 43 +++++ .../sections/status/status.controller.spec.ts | 79 +++++++++ .../sections/status/status.service.spec.ts | 56 +++++++ 21 files changed, 1329 insertions(+), 31 deletions(-) create mode 100644 apps/api-v2/jest.config.ts create mode 100644 apps/api-v2/test/bootstrap/main.spec.ts create mode 100644 apps/api-v2/test/common/db/external/cachet.service.spec.ts create mode 100644 apps/api-v2/test/common/db/prisma.service.spec.ts create mode 100644 apps/api-v2/test/common/decorators/filter.decorator.spec.ts create mode 100644 apps/api-v2/test/common/decorators/pagination.decorator.spec.ts create mode 100644 apps/api-v2/test/common/decorators/sorting.decorator.spec.ts create mode 100644 apps/api-v2/test/common/guards/auth.guard.spec.ts create mode 100644 apps/api-v2/test/common/interceptors/error.interceptor.spec.ts create mode 100644 apps/api-v2/test/common/interceptors/response.interceptor.spec.ts create mode 100644 apps/api-v2/test/sections/applications/applications.controller.spec.ts create mode 100644 apps/api-v2/test/sections/applications/applications.service.spec.ts create mode 100644 apps/api-v2/test/sections/auth/auth.controller.spec.ts create mode 100644 apps/api-v2/test/sections/auth/auth.service.spec.ts create mode 100644 apps/api-v2/test/sections/claims/claims.controller.spec.ts create mode 100644 apps/api-v2/test/sections/claims/claims.service.spec.ts create mode 100644 apps/api-v2/test/sections/status/status.controller.spec.ts create mode 100644 apps/api-v2/test/sections/status/status.service.spec.ts diff --git a/apps/api-v2/jest.config.ts b/apps/api-v2/jest.config.ts new file mode 100644 index 00000000..8b212bce --- /dev/null +++ b/apps/api-v2/jest.config.ts @@ -0,0 +1,36 @@ +import type { Config } from 'jest'; + +const config: Config = { + moduleFileExtensions: ['js', 'json', 'ts'], + + // IMPORTANT: make project root stable + rootDir: '.', + + testRegex: 'test/.*\\.spec\\.ts$', + + transform: { + '^.+\\.(t|j)s$': [ + 'ts-jest', + { + tsconfig: '/tsconfig.spec.json', + }, + ], + }, + + moduleNameMapper: { + '^src/(.*)$': '/src/$1', + }, + + collectCoverageFrom: [ + 'src/**/*.{ts,js}', + '!src/**/*.spec.{ts,js}', + '!src/**/*.test.{ts,js}', + '!src/main.ts', + ], + + coverageDirectory: '/coverage', + + testEnvironment: 'node', +}; + +export default config; \ No newline at end of file diff --git a/apps/api-v2/package.json b/apps/api-v2/package.json index 60177547..44796e6e 100644 --- a/apps/api-v2/package.json +++ b/apps/api-v2/package.json @@ -62,32 +62,5 @@ "prettier": "@repo/prettier-config", "lint-staged": { "*": "prettier --write --ignore-unknown" - }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "test", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": [ - "ts-jest", - { - "tsconfig": "/../tsconfig.spec.json" - } - ] - }, - "moduleNameMapper": { - "^src/(.*)$": "/../src/$1" - }, - "collectCoverageFrom": [ - "../src/**/*.{ts,js}", - "!../src/**/*.spec.{ts,js}", - "!../src/**/*.test.{ts,js}" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" } } diff --git a/apps/api-v2/src/main.ts b/apps/api-v2/src/main.ts index 758f5e69..f27c5ef2 100644 --- a/apps/api-v2/src/main.ts +++ b/apps/api-v2/src/main.ts @@ -6,19 +6,19 @@ import { AppModule } from "./app.module"; import { ExceptionsFilter } from "./common/interceptors/error.interceptor"; import { ResponseInterceptor } from "./common/interceptors/response.interceptor"; -async function bootstrap() { +export async function bootstrap() { const app = await NestFactory.create(AppModule); const { httpAdapter } = app.get(HttpAdapterHost); app.enableShutdownHooks(); app.enableCors(); app.use(helmet()); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: "2", }); - // Add global pipes, filters, and interceptors app.useGlobalPipes( new ValidationPipe({ whitelist: true, @@ -29,10 +29,10 @@ async function bootstrap() { }, }), ); + app.useGlobalFilters(new ExceptionsFilter(httpAdapter)); app.useGlobalInterceptors(new ResponseInterceptor()); - // Add Swagger UI const config = new DocumentBuilder() .setTitle("BuildTheEarth API") .setDescription( @@ -47,7 +47,9 @@ async function bootstrap() { bearerFormat: "JWT", }) .build(); + const documentFactory = () => SwaggerModule.createDocument(app, config); + SwaggerModule.setup("/v2/docs", app, documentFactory, { jsonDocumentUrl: "/v2/docs.json", yamlDocumentUrl: "/v2/docs.yaml", @@ -56,4 +58,6 @@ async function bootstrap() { await app.listen(process.env.PORT ?? 8080); } -bootstrap(); +if (require.main === module) { + bootstrap(); +} \ No newline at end of file diff --git a/apps/api-v2/test/bootstrap/main.spec.ts b/apps/api-v2/test/bootstrap/main.spec.ts new file mode 100644 index 00000000..e8ee39b2 --- /dev/null +++ b/apps/api-v2/test/bootstrap/main.spec.ts @@ -0,0 +1,96 @@ +jest.mock('@nestjs/core', () => { + const actual = jest.requireActual('@nestjs/core'); + return { + ...actual, + NestFactory: { + create: jest.fn(), + }, + }; +}); + +jest.mock('@nestjs/swagger', () => { + const actual = jest.requireActual('@nestjs/swagger'); + return { + ...actual, + SwaggerModule: { + createDocument: jest.fn(() => ({ openapi: '3.0.0' })), + setup: jest.fn(), + }, + }; +}); + +jest.mock('helmet', () => { + return jest.fn(() => jest.fn()); // middleware function +}); + +import { ValidationPipe, VersioningType } from '@nestjs/common'; +import { NestFactory } from '@nestjs/core'; +import { SwaggerModule } from '@nestjs/swagger'; +import { AppModule } from 'src/app.module'; +import { ExceptionsFilter } from 'src/common/interceptors/error.interceptor'; +import { ResponseInterceptor } from 'src/common/interceptors/response.interceptor'; +import { bootstrap } from '../../src/main'; + +const flushPromises = () => new Promise((r) => setImmediate(r)); + +describe('bootstrap', () => { + beforeEach(() => { + jest.resetModules(); + jest.clearAllMocks(); + process.env.PORT = '4321'; + }); + + afterEach(() => { + delete process.env.PORT; + }); + + it('should configure the application and start listening', async () => { + const httpAdapter = { reply: jest.fn() }; + + const app = { + get: jest.fn(() => ({ httpAdapter })), + enableShutdownHooks: jest.fn(), + enableCors: jest.fn(), + use: jest.fn(), + enableVersioning: jest.fn(), + useGlobalPipes: jest.fn(), + useGlobalFilters: jest.fn(), + useGlobalInterceptors: jest.fn(), + listen: jest.fn().mockResolvedValue(undefined), + }; + + (NestFactory.create as jest.Mock).mockResolvedValue(app); + + await bootstrap(); + await flushPromises(); + + expect(NestFactory.create).toHaveBeenCalledWith(AppModule); + + expect(app.get).toHaveBeenCalledWith(expect.anything()); + expect(app.enableShutdownHooks).toHaveBeenCalled(); + expect(app.enableCors).toHaveBeenCalled(); + + expect(app.use).toHaveBeenCalled(); // don’t over-specify helmet internals + + expect(app.enableVersioning).toHaveBeenCalledWith({ + type: VersioningType.URI, + defaultVersion: '2', + }); + + expect(app.useGlobalPipes).toHaveBeenCalledWith(expect.any(ValidationPipe)); + expect(app.useGlobalFilters).toHaveBeenCalledWith(expect.any(ExceptionsFilter)); + expect(app.useGlobalInterceptors).toHaveBeenCalledWith(expect.any(ResponseInterceptor)); + + expect(SwaggerModule.setup).toHaveBeenCalledWith( + '/v2/docs', + app, + expect.any(Function), + { + jsonDocumentUrl: '/v2/docs.json', + yamlDocumentUrl: '/v2/docs.yaml', + }, + ); + + expect(app.listen).toHaveBeenCalledWith('4321'); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/common/db/external/cachet.service.spec.ts b/apps/api-v2/test/common/db/external/cachet.service.spec.ts new file mode 100644 index 00000000..9322ee1c --- /dev/null +++ b/apps/api-v2/test/common/db/external/cachet.service.spec.ts @@ -0,0 +1,46 @@ +import { of } from 'rxjs'; +import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; +import { CachetAPIService } from 'src/common/db/external/cachet.service'; + +describe('CachetAPIService', () => { + let httpService: { get: jest.Mock }; + let configService: { get: jest.Mock }; + let cachetAPIService: CachetAPIService; + + beforeEach(() => { + httpService = { get: jest.fn() }; + configService = { get: jest.fn(() => undefined) }; + cachetAPIService = new CachetAPIService(httpService as unknown as HttpService, configService as unknown as ConfigService); + (cachetAPIService as any).baseURL = 'https://cachet.example'; + (cachetAPIService as any).apiToken = 'token-123'; + }); + + it('should ping the cachet api', async () => { + httpService.get.mockReturnValue(of({ data: { data: 'Pong!' } })); + + await expect(cachetAPIService.testConnection()).resolves.toBe('Pong!'); + expect(httpService.get).toHaveBeenCalledWith('https://cachet.example/api/ping'); + }); + + it('should fetch global status', async () => { + httpService.get.mockReturnValue(of({ data: { data: { status: 'ok', message: 'good' } } })); + + await expect(cachetAPIService.getGlobalStatus()).resolves.toEqual({ status: 'ok', message: 'good' }); + expect(httpService.get).toHaveBeenCalledWith('https://cachet.example/api/status'); + }); + + it('should fetch components with an optional status filter', async () => { + httpService.get.mockReturnValue(of({ data: { data: [] } })); + + await expect(cachetAPIService.getComponents({ status: 2 })).resolves.toEqual([]); + expect(httpService.get).toHaveBeenCalledWith('https://cachet.example/api/components?per_page=30&filter%5Bstatus%5D=2'); + }); + + it('should fetch incidents', async () => { + httpService.get.mockReturnValue(of({ data: { data: [] } })); + + await expect(cachetAPIService.getIncidents()).resolves.toEqual([]); + expect(httpService.get).toHaveBeenCalledWith('https://cachet.example/api/incidents?per_page=30'); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/common/db/prisma.service.spec.ts b/apps/api-v2/test/common/db/prisma.service.spec.ts new file mode 100644 index 00000000..aac6fd8e --- /dev/null +++ b/apps/api-v2/test/common/db/prisma.service.spec.ts @@ -0,0 +1,12 @@ +import { PrismaService } from 'src/common/db/prisma.service'; + +describe('PrismaService', () => { + it('should connect on module init', async () => { + const prismaService = new PrismaService(); + prismaService.$connect = jest.fn(); + + await prismaService.onModuleInit(); + + expect(prismaService.$connect).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/common/decorators/filter.decorator.spec.ts b/apps/api-v2/test/common/decorators/filter.decorator.spec.ts new file mode 100644 index 00000000..c3e45857 --- /dev/null +++ b/apps/api-v2/test/common/decorators/filter.decorator.spec.ts @@ -0,0 +1,110 @@ +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; +import { Filter } from 'src/common/decorators/filter.decorator'; +import { FILTER_META, Filtered } from 'src/common/decorators/filtered.decorator'; + +const getParamFactory = (target: object, methodName: string) => { + const metadata = Reflect.getMetadata(ROUTE_ARGS_METADATA, (target as any).constructor ?? target, methodName) as Record; + const entry = Object.values(metadata).find((value: any) => typeof value.factory === 'function') as { + factory: (data: unknown, ctx: any) => unknown; + data: unknown; + }; + + if (!entry) { + throw new Error(`No parameter factory metadata found for ${methodName}`); + } + + return entry; +}; + +const createContext = (query: Record, methodName: string) => ({ + switchToHttp: () => ({ + getRequest: () => ({ query }), + }), + getHandler: () => FilterHarness.prototype[methodName as keyof typeof FilterHarness.prototype], +}); + +class FilterHarness { + noMetadata(@Filter() filter: unknown) { + return filter; + } + + @Filtered({ + fields: [ + { name: 'age', type: Number }, + { name: 'active', type: Boolean }, + { name: 'name', type: String }, + { name: 'raw' }, + ], + }) + withMetadata(@Filter() filter: unknown) { + return filter; + } +} + +describe('Filter decorator', () => { + it('should return an empty filter when no metadata is defined', () => { + const { factory, data } = getParamFactory(FilterHarness.prototype, 'noMetadata'); + const result = factory(data, createContext({}, 'noMetadata')) as { filter: Record }; + + expect(result).toEqual({ filter: {} }); + }); + + it('should coerce supported query values and skip invalid numbers', () => { + const { factory, data } = getParamFactory(FilterHarness.prototype, 'withMetadata'); + const result = factory( + data, + createContext( + { + age: '12', + active: 'false', + name: 'Alice', + raw: 'custom-value', + }, + 'withMetadata', + ), + ) as { filter: Record }; + + expect(result).toEqual({ + filter: { + age: 12, + active: false, + name: 'Alice', + raw: 'custom-value', + }, + }); + }); + + it('should ignore invalid booleans and non-numeric numbers', () => { + const { factory, data } = getParamFactory(FilterHarness.prototype, 'withMetadata'); + const result = factory( + data, + createContext( + { + age: 'nope', + active: 'maybe', + name: 'Alice', + raw: 'fallback', + }, + 'withMetadata', + ), + ) as { filter: Record }; + + expect(result).toEqual({ + filter: { + name: 'Alice', + raw: 'fallback', + }, + }); + }); + + it('should set filter metadata through the Filtered decorator', () => { + expect(Reflect.getMetadata(FILTER_META, FilterHarness.prototype.withMetadata)).toEqual({ + fields: expect.arrayContaining([ + expect.objectContaining({ name: 'age' }), + expect.objectContaining({ name: 'active' }), + expect.objectContaining({ name: 'name' }), + expect.objectContaining({ name: 'raw' }), + ]), + }); + }); +}); diff --git a/apps/api-v2/test/common/decorators/pagination.decorator.spec.ts b/apps/api-v2/test/common/decorators/pagination.decorator.spec.ts new file mode 100644 index 00000000..3aea9d04 --- /dev/null +++ b/apps/api-v2/test/common/decorators/pagination.decorator.spec.ts @@ -0,0 +1,72 @@ +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; +import { Pagination } from 'src/common/decorators/pagination.decorator'; +import { PAGINATION_META, Paginated } from 'src/common/decorators/paginated.decorator'; + +const getParamFactory = (target: object, methodName: string) => { + const metadata = Reflect.getMetadata(ROUTE_ARGS_METADATA, (target as any).constructor ?? target, methodName) as Record; + const entry = Object.values(metadata).find((value: any) => typeof value.factory === 'function') as { + factory: (data: unknown, ctx: any) => unknown; + data: unknown; + }; + + if (!entry) { + throw new Error(`No parameter factory metadata found for ${methodName}`); + } + + return entry; +}; + +const createContext = (query: Record, methodName: string) => ({ + switchToHttp: () => ({ + getRequest: () => ({ query }), + }), + getHandler: () => PaginationHarness.prototype[methodName as keyof typeof PaginationHarness.prototype], +}); + +class PaginationHarness { + noMetadata(@Pagination() pagination: unknown) { + return pagination; + } + + @Paginated({ defaultPage: 3, defaultLimit: 5, maxLimit: 50 }) + withMetadata(@Pagination() pagination: unknown) { + return pagination; + } +} + +describe('Pagination decorator', () => { + it('should return default values when no metadata is defined', () => { + const { factory, data } = getParamFactory(PaginationHarness.prototype, 'noMetadata'); + const result = factory(data, createContext({}, 'noMetadata')) as { page: number; limit: number }; + + expect(result).toEqual({ page: 1, limit: 20 }); + }); + + it('should parse query values and honor custom defaults and limits', () => { + const { factory, data } = getParamFactory(PaginationHarness.prototype, 'withMetadata'); + const result = factory( + data, + createContext({ page: '2', limit: '100' }, 'withMetadata'), + ) as { page: number; limit: number }; + + expect(result).toEqual({ page: 2, limit: 50 }); + }); + + it('should fall back to defaults for invalid values', () => { + const { factory, data } = getParamFactory(PaginationHarness.prototype, 'withMetadata'); + const result = factory( + data, + createContext({ page: 'zero', limit: '-1' }, 'withMetadata'), + ) as { page: number; limit: number }; + + expect(result).toEqual({ page: 3, limit: 5 }); + }); + + it('should set pagination metadata through the Paginated decorator', () => { + expect(Reflect.getMetadata(PAGINATION_META, PaginationHarness.prototype.withMetadata)).toEqual({ + defaultPage: 3, + defaultLimit: 5, + maxLimit: 50, + }); + }); +}); diff --git a/apps/api-v2/test/common/decorators/sorting.decorator.spec.ts b/apps/api-v2/test/common/decorators/sorting.decorator.spec.ts new file mode 100644 index 00000000..4a03abec --- /dev/null +++ b/apps/api-v2/test/common/decorators/sorting.decorator.spec.ts @@ -0,0 +1,78 @@ +import { BadRequestException } from '@nestjs/common'; +import { ROUTE_ARGS_METADATA } from '@nestjs/common/constants'; +import { Sorting } from 'src/common/decorators/sorting.decorator'; +import { SORTING_META, Sortable } from 'src/common/decorators/sortable.decorator'; + +const getParamFactory = (target: object, methodName: string) => { + const metadata = Reflect.getMetadata(ROUTE_ARGS_METADATA, (target as any).constructor ?? target, methodName) as Record; + const entry = Object.values(metadata).find((value: any) => typeof value.factory === 'function') as { + factory: (data: unknown, ctx: any) => unknown; + data: unknown; + }; + + if (!entry) { + throw new Error(`No parameter factory metadata found for ${methodName}`); + } + + return entry; +}; + +const createContext = (query: Record, methodName: string) => ({ + switchToHttp: () => ({ + getRequest: () => ({ query }), + }), + getHandler: () => SortingHarness.prototype[methodName as keyof typeof SortingHarness.prototype], +}); + +class SortingHarness { + plain(@Sorting() sorting: unknown) { + return sorting; + } + + @Sortable({ allowedFields: ['name', 'age'], defaultOrder: 'desc', defaultSortBy: 'age' }) + withMetadata(@Sorting() sorting: unknown) { + return sorting; + } +} + +describe('Sorting decorator', () => { + it('should return default values when no metadata is defined', () => { + const { factory, data } = getParamFactory(SortingHarness.prototype, 'plain'); + const result = factory(data, createContext({}, 'plain')) as { sortBy?: string; order?: 'asc' | 'desc' }; + + expect(result).toEqual({ sortBy: undefined, order: 'asc' }); + }); + + it('should parse valid sort and order values', () => { + const { factory, data } = getParamFactory(SortingHarness.prototype, 'withMetadata'); + const result = factory( + data, + createContext({ sortBy: 'name', order: 'desc' }, 'withMetadata'), + ) as { sortBy?: string; order?: 'asc' | 'desc' }; + + expect(result).toEqual({ sortBy: 'name', order: 'desc' }); + }); + + it('should reject an invalid sort field', () => { + const { factory, data } = getParamFactory(SortingHarness.prototype, 'withMetadata'); + + expect(() => + factory(data, createContext({ sortBy: 'unknown', order: 'desc' }, 'withMetadata')), + ).toThrow(BadRequestException); + }); + + it('should fall back to configured defaults when query parameters are absent', () => { + const { factory, data } = getParamFactory(SortingHarness.prototype, 'withMetadata'); + const result = factory(data, createContext({}, 'withMetadata')) as { sortBy?: string; order?: 'asc' | 'desc' }; + + expect(result).toEqual({ sortBy: 'age', order: 'desc' }); + }); + + it('should set sorting metadata through the Sortable decorator', () => { + expect(Reflect.getMetadata(SORTING_META, SortingHarness.prototype.withMetadata)).toEqual({ + allowedFields: ['name', 'age'], + defaultOrder: 'desc', + defaultSortBy: 'age', + }); + }); +}); diff --git a/apps/api-v2/test/common/guards/auth.guard.spec.ts b/apps/api-v2/test/common/guards/auth.guard.spec.ts new file mode 100644 index 00000000..72db1727 --- /dev/null +++ b/apps/api-v2/test/common/guards/auth.guard.spec.ts @@ -0,0 +1,73 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { AuthGuard } from 'src/common/guards/auth.guard'; + +describe('AuthGuard', () => { + let authGuard: AuthGuard; + let jwtService: { + verifyAsync: jest.Mock; + }; + let reflector: { + getAllAndOverride: jest.Mock; + }; + + const createContext = (authorization?: string) => { + const request = { headers: { authorization } } as any; + return { + getHandler: jest.fn(), + getClass: jest.fn(), + switchToHttp: jest.fn(() => ({ getRequest: jest.fn(() => request) })), + } as any; + }; + + beforeEach(() => { + jwtService = { verifyAsync: jest.fn() }; + reflector = { getAllAndOverride: jest.fn() }; + authGuard = new AuthGuard(jwtService as unknown as JwtService, reflector as unknown as Reflector); + }); + + it('should allow public routes without checking auth', async () => { + reflector.getAllAndOverride.mockImplementation((key: string) => key === 'shouldSkipAuth'); + + await expect(authGuard.canActivate(createContext())).resolves.toBe(true); + expect(jwtService.verifyAsync).not.toHaveBeenCalled(); + }); + + it('should allow optional auth routes without a token', async () => { + reflector.getAllAndOverride.mockImplementation((key: string) => key === 'isAuthOptional'); + + await expect(authGuard.canActivate(createContext())).resolves.toBe(true); + expect(jwtService.verifyAsync).not.toHaveBeenCalled(); + }); + + it('should reject missing tokens on protected routes', async () => { + reflector.getAllAndOverride.mockReturnValue(false); + + await expect(authGuard.canActivate(createContext())).rejects.toThrow(UnauthorizedException); + }); + + it('should attach the token payload when verification succeeds', async () => { + const payload = { id: 'team-123' }; + jwtService.verifyAsync.mockResolvedValue(payload); + reflector.getAllAndOverride.mockReturnValue(false); + const context = createContext('Bearer jwt-token'); + + await expect(authGuard.canActivate(context)).resolves.toBe(true); + expect(jwtService.verifyAsync).toHaveBeenCalledWith('jwt-token'); + expect(context.switchToHttp().getRequest().token).toEqual(payload); + }); + + it('should reject invalid tokens', async () => { + jwtService.verifyAsync.mockRejectedValue(new Error('invalid')); + reflector.getAllAndOverride.mockReturnValue(false); + + await expect(authGuard.canActivate(createContext('Bearer jwt-token'))).rejects.toThrow(UnauthorizedException); + }); + + it('should ignore malformed authorization headers', async () => { + reflector.getAllAndOverride.mockReturnValue(false); + + await expect(authGuard.canActivate(createContext('Basic abc'))).rejects.toThrow(UnauthorizedException); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/common/interceptors/error.interceptor.spec.ts b/apps/api-v2/test/common/interceptors/error.interceptor.spec.ts new file mode 100644 index 00000000..010d42dc --- /dev/null +++ b/apps/api-v2/test/common/interceptors/error.interceptor.spec.ts @@ -0,0 +1,72 @@ +import { ArgumentsHost, HttpException, Logger } from '@nestjs/common'; +import { AbstractHttpAdapter } from '@nestjs/core'; +import { ExceptionsFilter } from 'src/common/interceptors/error.interceptor'; + +describe('ExceptionsFilter', () => { + let httpAdapter: { + reply: jest.Mock; + }; + let filter: ExceptionsFilter; + let loggerErrorSpy: jest.SpyInstance; + let loggerDebugSpy: jest.SpyInstance; + + const createHost = () => + ({ + switchToHttp: () => ({ + getRequest: () => ({ url: '/test', method: 'GET' }), + getResponse: () => ({}), + }), + } as unknown as ArgumentsHost); + + beforeEach(() => { + httpAdapter = { reply: jest.fn() }; + filter = new ExceptionsFilter(httpAdapter as unknown as AbstractHttpAdapter); + loggerErrorSpy = jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined as any); + loggerDebugSpy = jest.spyOn(Logger.prototype, 'debug').mockImplementation(() => undefined as any); + }); + + afterEach(() => { + loggerErrorSpy.mockRestore(); + loggerDebugSpy.mockRestore(); + }); + + it('should format http exceptions with message arrays', () => { + const exception = new HttpException( + { message: ['first', 'second'], error: 'BadRequest' }, + 400, + ); + + filter.catch(exception, createHost()); + + expect(httpAdapter.reply).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 400, + path: '/test', + error: 'BadRequest', + message: 'first, second', + }), + 400, + ); + expect(loggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('should format non-http exceptions and log them', () => { + const exception = new Error('boom'); + + filter.catch(exception, createHost()); + + expect(httpAdapter.reply).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + status: 500, + path: '/test', + error: 'Error', + message: 'boom', + }), + 500, + ); + expect(loggerErrorSpy).toHaveBeenCalled(); + expect(loggerDebugSpy).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/common/interceptors/response.interceptor.spec.ts b/apps/api-v2/test/common/interceptors/response.interceptor.spec.ts new file mode 100644 index 00000000..41c03ee1 --- /dev/null +++ b/apps/api-v2/test/common/interceptors/response.interceptor.spec.ts @@ -0,0 +1,45 @@ +import { of } from 'rxjs'; +import { ResponseInterceptor } from 'src/common/interceptors/response.interceptor'; + +describe('ResponseInterceptor', () => { + it('should wrap a non-paginated response', (done) => { + const interceptor = new ResponseInterceptor(); + const context = { + switchToHttp: () => ({ getResponse: () => ({ statusCode: 201 }) }), + } as any; + const next = { handle: () => of({ id: 'item-1' }) } as any; + + interceptor.intercept(context, next).subscribe((result) => { + expect(result).toEqual({ + status: 201, + message: 'Success', + data: { id: 'item-1' }, + }); + done(); + }); + }); + + it('should wrap paginated responses with meta', (done) => { + const interceptor = new ResponseInterceptor(); + const context = { + switchToHttp: () => ({ getResponse: () => ({ statusCode: 200 }) }), + } as any; + const next = { + handle: () => + of({ + data: [{ id: 'item-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }), + } as any; + + interceptor.intercept(context, next).subscribe((result) => { + expect(result).toEqual({ + status: 200, + message: 'Success', + data: [{ id: 'item-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + done(); + }); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/sections/applications/applications.controller.spec.ts b/apps/api-v2/test/sections/applications/applications.controller.spec.ts new file mode 100644 index 00000000..b0faccd2 --- /dev/null +++ b/apps/api-v2/test/sections/applications/applications.controller.spec.ts @@ -0,0 +1,106 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Request } from 'express'; +import { ApplicationsController } from 'src/sections/applications/applications.controller'; +import { ApplicationsService } from 'src/sections/applications/applications.service'; + +describe('ApplicationsController', () => { + let applicationsController: ApplicationsController; + let applicationsService: { + findAll: jest.Mock; + create: jest.Mock; + findById: jest.Mock; + review: jest.Mock; + }; + + beforeEach(async () => { + applicationsService = { + findAll: jest.fn(), + create: jest.fn(), + findById: jest.fn(), + review: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ApplicationsController], + providers: [ + { + provide: ApplicationsService, + useValue: applicationsService, + }, + ], + }).compile(); + + applicationsController = module.get(ApplicationsController); + }); + + describe('getApplications', () => { + it('should request applications for the authenticated team', async () => { + applicationsService.findAll.mockResolvedValue({ + data: [{ id: 'application-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + + const pagination = { page: 1, limit: 20 }; + const sorting = { sortBy: 'createdAt', order: 'desc' }; + const filter = { filter: { status: 'SEND' } }; + const req = { token: { id: 'team-123' } } as Request; + + const result = await applicationsController.getApplications( + pagination as never, + sorting as never, + filter as never, + req, + ); + + expect(applicationsService.findAll).toHaveBeenCalledWith( + pagination, + 'createdAt', + 'desc', + { status: 'SEND' }, + 'team-123', + ); + expect(result).toEqual({ + data: [{ id: 'application-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + }); + + describe('createApplication', () => { + it('should create an application for the authenticated team', async () => { + applicationsService.create.mockResolvedValue({ id: 'application-1' }); + + const req = { token: { id: 'team-123' } } as Request; + const dto = { userId: 'user-1' }; + + const result = await applicationsController.createApplication(dto as never, req); + + expect(applicationsService.create).toHaveBeenCalledWith(dto, 'team-123'); + expect(result).toEqual({ id: 'application-1' }); + }); + }); + + describe('getApplicationById', () => { + it('should fetch the application by id', async () => { + applicationsService.findById.mockResolvedValue({ id: 'application-1' }); + + const result = await applicationsController.getApplicationById('application-1'); + + expect(applicationsService.findById).toHaveBeenCalledWith('application-1'); + expect(result).toEqual({ id: 'application-1' }); + }); + }); + + describe('reviewApplication', () => { + it('should review the application with the provided payload', async () => { + applicationsService.review.mockResolvedValue({ id: 'application-1', status: 'REVIEWING' }); + + const dto = { status: 'REVIEWING', reason: 'needs changes' }; + + const result = await applicationsController.reviewApplication('application-1', dto as never); + + expect(applicationsService.review).toHaveBeenCalledWith('application-1', dto); + expect(result).toEqual({ id: 'application-1', status: 'REVIEWING' }); + }); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/sections/applications/applications.service.spec.ts b/apps/api-v2/test/sections/applications/applications.service.spec.ts new file mode 100644 index 00000000..8fb5d6f4 --- /dev/null +++ b/apps/api-v2/test/sections/applications/applications.service.spec.ts @@ -0,0 +1,150 @@ +import { ApplicationsService } from 'src/sections/applications/applications.service'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ApplicationStatus } from '@repo/db'; + +describe('ApplicationsService', () => { + let applicationsService: ApplicationsService; + let prismaService: { + application: { + findMany: jest.Mock; + count: jest.Mock; + create: jest.Mock; + findUnique: jest.Mock; + update: jest.Mock; + }; + user: { + findUnique: jest.Mock; + }; + }; + + beforeEach(() => { + prismaService = { + application: { + findMany: jest.fn(), + count: jest.fn(), + create: jest.fn(), + findUnique: jest.fn(), + update: jest.fn(), + }, + user: { + findUnique: jest.fn(), + }, + }; + applicationsService = new ApplicationsService(prismaService as unknown as PrismaService); + }); + + describe('findAll', () => { + it('should apply pagination, sorting, filter, and build team constraints', async () => { + prismaService.application.findMany.mockResolvedValue([{ id: 'application-1' }]); + prismaService.application.count.mockResolvedValue(4); + + const result = await applicationsService.findAll( + { page: 2, limit: 2 } as any, + 'createdAt', + 'desc', + { status: ApplicationStatus.SEND } as any, + 'team-123', + ); + + expect(prismaService.application.findMany).toHaveBeenCalledWith({ + where: { status: ApplicationStatus.SEND, buildteamId: 'team-123' }, + orderBy: { createdAt: 'desc' }, + skip: 2, + take: 2, + }); + expect(result).toEqual({ + data: [{ id: 'application-1' }], + meta: { page: 2, perPage: 2, totalItems: 4, totalPages: 2 }, + }); + }); + }); + + describe('create', () => { + it('should create an application with defaults', async () => { + prismaService.user.findUnique.mockResolvedValue({ id: 'user-1' }); + prismaService.application.create.mockResolvedValue({ id: 'application-1' }); + + const result = await applicationsService.create({ userId: 'user-1' } as any, 'team-123'); + + expect(prismaService.application.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + id: expect.any(String), + buildteamId: 'team-123', + userId: 'user-1', + reviewerId: null, + status: ApplicationStatus.SEND, + reviewedAt: null, + reason: null, + claimId: null, + trial: false, + }), + }); + expect(result).toEqual({ id: 'application-1' }); + }); + + it('should reject missing users', async () => { + prismaService.user.findUnique.mockResolvedValue(null); + + await expect(applicationsService.create({ userId: 'missing-user' } as any, 'team-123')).rejects.toThrow( + BadRequestException, + ); + }); + }); + + describe('findById', () => { + it('should return the found application', async () => { + prismaService.application.findUnique.mockResolvedValue({ id: 'application-1' }); + + await expect(applicationsService.findById('application-1')).resolves.toEqual({ id: 'application-1' }); + }); + + it('should throw when the application is missing', async () => { + prismaService.application.findUnique.mockResolvedValue(null); + + await expect(applicationsService.findById('application-1')).rejects.toThrow(NotFoundException); + }); + }); + + describe('review', () => { + it('should update the application with a terminal status and reviewedAt timestamp', async () => { + prismaService.application.update.mockResolvedValue({ id: 'application-1' }); + + await applicationsService.review('application-1', { + status: ApplicationStatus.ACCEPTED, + reviewerId: 'reviewer-1', + reviewedAt: '2026-01-01T00:00:00.000Z', + reason: 'ok', + claimId: 'claim-1', + trial: true, + } as any); + + expect(prismaService.application.update).toHaveBeenCalledWith({ + where: { id: 'application-1' }, + data: { + reviewerId: 'reviewer-1', + status: ApplicationStatus.ACCEPTED, + reviewedAt: '2026-01-01T00:00:00.000Z', + reason: 'ok', + claimId: 'claim-1', + trial: true, + }, + }); + }); + + it('should clear reviewedAt while the application is still reviewing', async () => { + prismaService.application.update.mockResolvedValue({ id: 'application-1' }); + + await applicationsService.review('application-1', {} as any); + + expect(prismaService.application.update).toHaveBeenCalledWith({ + where: { id: 'application-1' }, + data: expect.objectContaining({ + status: ApplicationStatus.REVIEWING, + reviewedAt: null, + trial: false, + }), + }); + }); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts b/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts index c8ddcc4f..30def343 100644 --- a/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts +++ b/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts @@ -6,11 +6,13 @@ import { ApplicationQuestionsService } from 'src/sections/applications/questions describe('ApplicationQuestionsController', () => { let applicationQuestionsController: ApplicationQuestionsController; let applicationQuestionsService: { + findAll: jest.Mock; delete: jest.Mock; }; beforeEach(async () => { applicationQuestionsService = { + findAll: jest.fn(), delete: jest.fn(), }; @@ -27,6 +29,39 @@ describe('ApplicationQuestionsController', () => { applicationQuestionsController = module.get(ApplicationQuestionsController); }); + describe('getApplicationQuestions', () => { + it('should request application questions for the authenticated team', async () => { + applicationQuestionsService.findAll.mockResolvedValue({ + data: [{ id: 'question-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + + const pagination = { page: 1, limit: 20 }; + const sorting = { sortBy: 'title', order: 'asc' }; + const filter = { filter: { required: true } }; + const req = { token: { id: 'team-123' } } as Request; + + const result = await applicationQuestionsController.getApplicationQuestions( + pagination as never, + sorting as never, + filter as never, + req, + ); + + expect(applicationQuestionsService.findAll).toHaveBeenCalledWith( + pagination, + 'title', + 'asc', + { required: true }, + 'team-123', + ); + expect(result).toEqual({ + data: [{ id: 'question-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + }); + describe('deleteApplicationQuestion', () => { it('should delete the question for the authenticated team', async () => { applicationQuestionsService.delete.mockResolvedValue(undefined); diff --git a/apps/api-v2/test/sections/auth/auth.controller.spec.ts b/apps/api-v2/test/sections/auth/auth.controller.spec.ts new file mode 100644 index 00000000..3e4c54c7 --- /dev/null +++ b/apps/api-v2/test/sections/auth/auth.controller.spec.ts @@ -0,0 +1,51 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Request } from 'express'; +import { AuthController } from 'src/sections/auth/auth.controller'; +import { AuthService } from 'src/sections/auth/auth.service'; + +describe('AuthController', () => { + let authController: AuthController; + let authService: { + generateAccessToken: jest.Mock; + }; + + beforeEach(async () => { + authService = { + generateAccessToken: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [AuthController], + providers: [ + { + provide: AuthService, + useValue: authService, + }, + ], + }).compile(); + + authController = module.get(AuthController); + }); + + describe('generateAccessToken', () => { + it('should generate an access token for the provided team credentials', async () => { + authService.generateAccessToken.mockResolvedValue({ access_token: 'jwt-token' }); + + const result = await authController.generateAccessToken({ + buildTeamId: 'team-123', + token: 'secret-token', + }); + + expect(authService.generateAccessToken).toHaveBeenCalledWith('team-123', 'secret-token'); + expect(result).toEqual({ access_token: 'jwt-token' }); + }); + }); + + describe('getProfile', () => { + it('should return the attached token payload', () => { + const req = { token: { id: 'team-123', slug: 'buildteam' } } as Request; + + expect(authController.getProfile(req)).toEqual({ id: 'team-123', slug: 'buildteam' }); + }); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/sections/auth/auth.service.spec.ts b/apps/api-v2/test/sections/auth/auth.service.spec.ts new file mode 100644 index 00000000..644ca456 --- /dev/null +++ b/apps/api-v2/test/sections/auth/auth.service.spec.ts @@ -0,0 +1,79 @@ +import { UnauthorizedException } from '@nestjs/common'; +import { AuthService } from 'src/sections/auth/auth.service'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { JwtService } from '@nestjs/jwt'; + +describe('AuthService', () => { + let authService: AuthService; + let prismaService: { + buildTeam: { + findUnique: jest.Mock; + }; + }; + let jwtService: { + signAsync: jest.Mock; + verifyAsync: jest.Mock; + }; + + beforeEach(() => { + prismaService = { + buildTeam: { findUnique: jest.fn() }, + }; + jwtService = { + signAsync: jest.fn(), + verifyAsync: jest.fn(), + }; + authService = new AuthService(prismaService as unknown as PrismaService, jwtService as unknown as JwtService); + }); + + describe('generateAccessToken', () => { + it('should sign a payload for a matching build team token', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ + id: 'team-123', + slug: 'build-the-earth', + token: 'secret-token', + }); + jwtService.signAsync.mockResolvedValue('jwt-token'); + + const result = await authService.generateAccessToken('team-123', 'secret-token'); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith({ + where: { id: 'team-123' }, + select: { id: true, slug: true, token: true }, + }); + expect(jwtService.signAsync).toHaveBeenCalledWith( + expect.objectContaining({ + sub: 'team-123', + id: 'team-123', + slug: 'build-the-earth', + }), + ); + expect(result).toEqual({ access_token: 'jwt-token' }); + }); + + it('should reject invalid credentials', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue(null); + + await expect(authService.generateAccessToken('team-123', 'secret-token')).rejects.toThrow( + UnauthorizedException, + ); + }); + }); + + describe('validateJwt', () => { + it('should return the verified payload', async () => { + jwtService.verifyAsync.mockResolvedValue({ id: 'team-123', slug: 'build-the-earth' }); + + await expect(authService.validateJwt('jwt-token')).resolves.toEqual({ + id: 'team-123', + slug: 'build-the-earth', + }); + }); + + it('should return null when verification fails', async () => { + jwtService.verifyAsync.mockRejectedValue(new Error('invalid')); + + await expect(authService.validateJwt('jwt-token')).resolves.toBeNull(); + }); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/sections/claims/claims.controller.spec.ts b/apps/api-v2/test/sections/claims/claims.controller.spec.ts new file mode 100644 index 00000000..baa19b0b --- /dev/null +++ b/apps/api-v2/test/sections/claims/claims.controller.spec.ts @@ -0,0 +1,82 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Request } from 'express'; +import { ClaimsController } from 'src/sections/claims/claims.controller'; +import { ClaimsService } from 'src/sections/claims/claims.service'; + +describe('ClaimsController', () => { + let claimsController: ClaimsController; + let claimsService: { + findAll: jest.Mock; + }; + + beforeEach(async () => { + claimsService = { + findAll: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ClaimsController], + providers: [ + { + provide: ClaimsService, + useValue: claimsService, + }, + ], + }).compile(); + + claimsController = module.get(ClaimsController); + }); + + describe('findAll', () => { + it('should default to the authenticated team when no team filter is provided', async () => { + claimsService.findAll.mockResolvedValue({ + data: [], + meta: { page: 1, perPage: 20, totalItems: 0, totalPages: 0 }, + }); + + const pagination = { page: 1, limit: 20 }; + const filter = { filter: { active: true } }; + const req = { token: { id: 'team-123' } } as Request; + + await claimsController.findAll(pagination as never, filter as never, req); + + expect(claimsService.findAll).toHaveBeenCalledWith(pagination, { + active: true, + buildTeamId: 'team-123', + }); + }); + + it('should filter by explicit build team id', async () => { + claimsService.findAll.mockResolvedValue({ + data: [], + meta: { page: 1, perPage: 20, totalItems: 0, totalPages: 0 }, + }); + + const pagination = { page: 1, limit: 20 }; + const filter = { filter: { team: 'team-456', active: false } }; + + await claimsController.findAll(pagination as never, filter as never, {} as Request); + + expect(claimsService.findAll).toHaveBeenCalledWith(pagination, { + active: false, + buildTeamId: 'team-456', + }); + }); + + it('should filter by team slug when requested', async () => { + claimsService.findAll.mockResolvedValue({ + data: [], + meta: { page: 1, perPage: 20, totalItems: 0, totalPages: 0 }, + }); + + const pagination = { page: 1, limit: 20 }; + const filter = { filter: { team: 'build-the-earth', slug: true } }; + + await claimsController.findAll(pagination as never, filter as never, {} as Request); + + expect(claimsService.findAll).toHaveBeenCalledWith(pagination, { + buildTeam: { slug: 'build-the-earth' }, + }); + }); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/sections/claims/claims.service.spec.ts b/apps/api-v2/test/sections/claims/claims.service.spec.ts new file mode 100644 index 00000000..f8102e26 --- /dev/null +++ b/apps/api-v2/test/sections/claims/claims.service.spec.ts @@ -0,0 +1,43 @@ +import { ClaimsService } from 'src/sections/claims/claims.service'; +import { PrismaService } from 'src/common/db/prisma.service'; + +describe('ClaimsService', () => { + let claimsService: ClaimsService; + let prismaService: { + claim: { + findMany: jest.Mock; + count: jest.Mock; + }; + }; + + beforeEach(() => { + prismaService = { + claim: { + findMany: jest.fn(), + count: jest.fn(), + }, + }; + claimsService = new ClaimsService(prismaService as unknown as PrismaService); + }); + + it('should paginate and include claim counts and images', async () => { + prismaService.claim.findMany.mockResolvedValue([{ id: 'claim-1' }]); + prismaService.claim.count.mockResolvedValue(3); + + const result = await claimsService.findAll({ page: 2, limit: 1 } as any, { active: true } as any); + + expect(prismaService.claim.findMany).toHaveBeenCalledWith({ + where: { active: true }, + skip: 1, + take: 1, + include: { + _count: { select: { builders: true, images: true } }, + images: { select: { id: true, name: true, hash: true } }, + }, + }); + expect(result).toEqual({ + data: [{ id: 'claim-1' }], + meta: { page: 2, perPage: 1, totalItems: 3, totalPages: 3 }, + }); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/sections/status/status.controller.spec.ts b/apps/api-v2/test/sections/status/status.controller.spec.ts new file mode 100644 index 00000000..bf86e710 --- /dev/null +++ b/apps/api-v2/test/sections/status/status.controller.spec.ts @@ -0,0 +1,79 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ServiceUnavailableException } from '@nestjs/common'; +import { StatusController } from 'src/sections/status/status.controller'; +import { StatusService } from 'src/sections/status/status.service'; + +describe('StatusController', () => { + let statusController: StatusController; + let statusService: { + testConnection: jest.Mock; + getGlobalStatus: jest.Mock; + getComponents: jest.Mock; + getIncidents: jest.Mock; + }; + + beforeEach(async () => { + statusService = { + testConnection: jest.fn(), + getGlobalStatus: jest.fn(), + getComponents: jest.fn(), + getIncidents: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [StatusController], + providers: [ + { + provide: StatusService, + useValue: statusService, + }, + ], + }).compile(); + + statusController = module.get(StatusController); + }); + + describe('testConnection', () => { + it('should return OK when the status service responds with Pong!', async () => { + statusService.testConnection.mockResolvedValue('Pong!'); + + await expect(statusController.testConnection()).resolves.toBe('OK'); + }); + + it('should throw when the status service is unreachable', async () => { + statusService.testConnection.mockResolvedValue('Nope'); + + await expect(statusController.testConnection()).rejects.toThrow(ServiceUnavailableException); + }); + }); + + describe('getGlobalStatus', () => { + it('should return the global status payload', async () => { + statusService.getGlobalStatus.mockResolvedValue({ status: 'ok', message: 'All good' }); + + await expect(statusController.getGlobalStatus()).resolves.toEqual({ + status: 'ok', + message: 'All good', + }); + }); + }); + + describe('getComponents', () => { + it('should forward the status filter to the service', async () => { + statusService.getComponents.mockResolvedValue([{ id: 1 }]); + + const filter = { filter: { status: 2 } }; + + await expect(statusController.getComponents(filter as never)).resolves.toEqual([{ id: 1 }]); + expect(statusService.getComponents).toHaveBeenCalledWith({ status: 2 }); + }); + }); + + describe('getIncidents', () => { + it('should return recent incidents from the service', async () => { + statusService.getIncidents.mockResolvedValue([{ id: 1 }]); + + await expect(statusController.getIncidents()).resolves.toEqual([{ id: 1 }]); + }); + }); +}); \ No newline at end of file diff --git a/apps/api-v2/test/sections/status/status.service.spec.ts b/apps/api-v2/test/sections/status/status.service.spec.ts new file mode 100644 index 00000000..60ae87e9 --- /dev/null +++ b/apps/api-v2/test/sections/status/status.service.spec.ts @@ -0,0 +1,56 @@ +import { StatusService } from 'src/sections/status/status.service'; +import { CachetAPIService } from 'src/common/db/external/cachet.service'; +import { ServiceUnavailableException } from '@nestjs/common'; + +describe('StatusService', () => { + let statusService: StatusService; + let cachetAPIService: { + testConnection: jest.Mock; + getGlobalStatus: jest.Mock; + getComponents: jest.Mock; + getIncidents: jest.Mock; + }; + + beforeEach(() => { + cachetAPIService = { + testConnection: jest.fn(), + getGlobalStatus: jest.fn(), + getComponents: jest.fn(), + getIncidents: jest.fn(), + }; + statusService = new StatusService(cachetAPIService as unknown as CachetAPIService); + }); + + it('should return OK only when the cachet service replies with Pong!', async () => { + cachetAPIService.testConnection.mockResolvedValue('Pong!'); + + await expect(statusService.testConnection()).resolves.toBe('Pong!'); + }); + + it('should translate cachet failures into service unavailable errors', async () => { + cachetAPIService.testConnection.mockRejectedValue(new Error('nope')); + + await expect(statusService.testConnection()).rejects.toThrow(ServiceUnavailableException); + }); + + it('should map component payloads', async () => { + cachetAPIService.getComponents.mockResolvedValue([ + { attributes: { id: 1, name: 'API', link: null, description: null, status: 1, meta: { type: 1 } } }, + ]); + + await expect(statusService.getComponents({ status: 2 })).resolves.toEqual([ + { id: 1, name: 'API', link: null, description: null, status: 1, type: 1 }, + ]); + expect(cachetAPIService.getComponents).toHaveBeenCalledWith({ status: 2 }); + }); + + it('should map incident payloads', async () => { + cachetAPIService.getIncidents.mockResolvedValue([ + { attributes: { id: 1, name: 'Incident', message: 'x', status: 1, created: 'c', occurred: 'o' } }, + ]); + + await expect(statusService.getIncidents()).resolves.toEqual([ + { id: 1, name: 'Incident', message: 'x', status: 1, created_at: 'c', occurred_at: 'o' }, + ]); + }); +}); \ No newline at end of file From 9d856530c6c8a34cb2d017a6bc3812093a1639e0 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:16:31 +0200 Subject: [PATCH 08/21] chore(mono): :lock: Sync lockfile with api-v2 dependencies The lockfile still carried jest 30.x entries that no workspace resolves to anymore, so a fresh `yarn install` for api-v2 rewrote them to match the pinned `@types/jest@^29`. Co-Authored-By: Claude Opus 5 --- yarn.lock | 236 +++++------------------------------------------------- 1 file changed, 21 insertions(+), 215 deletions(-) diff --git a/yarn.lock b/yarn.lock index 5db1a705..1ccb4027 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2250,13 +2250,6 @@ __metadata: languageName: node linkType: hard -"@jest/diff-sequences@npm:30.4.0": - version: 30.4.0 - resolution: "@jest/diff-sequences@npm:30.4.0" - checksum: 10c0/b4358b1b885098b905cb777f58788ddd45f90c4ebc3ce2c04fb1d4c9516f35ac2d9daef8263cd21c537bd7a52ab320f03e4ba9521677959ae20e3d405356b420 - languageName: node - linkType: hard - "@jest/environment@npm:^29.7.0": version: 29.7.0 resolution: "@jest/environment@npm:29.7.0" @@ -2269,15 +2262,6 @@ __metadata: languageName: node linkType: hard -"@jest/expect-utils@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/expect-utils@npm:30.4.1" - dependencies: - "@jest/get-type": "npm:30.1.0" - checksum: 10c0/6dea9e11ebcc7be68fea5950ae5a1b7ff9fd1490101ee8af0aede336b9934ab24a28bcafe2f1171dac0f95982406386c609ca2659b9132e1a9d419e8d69b9cd4 - languageName: node - linkType: hard - "@jest/expect-utils@npm:^29.7.0": version: 29.7.0 resolution: "@jest/expect-utils@npm:29.7.0" @@ -2311,13 +2295,6 @@ __metadata: languageName: node linkType: hard -"@jest/get-type@npm:30.1.0": - version: 30.1.0 - resolution: "@jest/get-type@npm:30.1.0" - checksum: 10c0/3e65fd5015f551c51ec68fca31bbd25b466be0e8ee8075d9610fa1c686ea1e70a942a0effc7b10f4ea9a338c24337e1ad97ff69d3ebacc4681b7e3e80d1b24ac - languageName: node - linkType: hard - "@jest/globals@npm:^29.7.0": version: 29.7.0 resolution: "@jest/globals@npm:29.7.0" @@ -2330,16 +2307,6 @@ __metadata: languageName: node linkType: hard -"@jest/pattern@npm:30.4.0": - version: 30.4.0 - resolution: "@jest/pattern@npm:30.4.0" - dependencies: - "@types/node": "npm:*" - jest-regex-util: "npm:30.4.0" - checksum: 10c0/05bc0799f84f3750bbbff0f9a546979efd0dbcee86c1be98b9e2811a68885809ec7b5cca39b8dda1497cb7cf17b7be936019fba8dfbcd9c53b181e03e67f4f82 - languageName: node - linkType: hard - "@jest/reporters@npm:^29.7.0": version: 29.7.0 resolution: "@jest/reporters@npm:29.7.0" @@ -2377,15 +2344,6 @@ __metadata: languageName: node linkType: hard -"@jest/schemas@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/schemas@npm:30.4.1" - dependencies: - "@sinclair/typebox": "npm:^0.34.0" - checksum: 10c0/96f388ebfc1974457fcbde2ad36c40a0b549cba3f624fe8d9d6e5903a152dc75e4043f4ac9ac7668622f2ecb0f9a4dcb9a38edf3bc0d52b82045b2bb2b69b72a - languageName: node - linkType: hard - "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -2453,21 +2411,6 @@ __metadata: languageName: node linkType: hard -"@jest/types@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/types@npm:30.4.1" - dependencies: - "@jest/pattern": "npm:30.4.0" - "@jest/schemas": "npm:30.4.1" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - "@types/istanbul-reports": "npm:^3.0.4" - "@types/node": "npm:*" - "@types/yargs": "npm:^17.0.33" - chalk: "npm:^4.1.2" - checksum: 10c0/4c79f6dbdb1c7eaab5da255fc696c7cae744759d4020e42da8aa63b37fe55ce594be73075fe1ee5407dd59d7e47975be9f674bfc81e91bae2c89c62d27ba55a1 - languageName: node - linkType: hard - "@jest/types@npm:^29.6.3": version: 29.6.3 resolution: "@jest/types@npm:29.6.3" @@ -3753,13 +3696,6 @@ __metadata: languageName: node linkType: hard -"@sinclair/typebox@npm:^0.34.0": - version: 0.34.49 - resolution: "@sinclair/typebox@npm:0.34.49" - checksum: 10c0/16b7d87f039a49b68c10bb4cdcae2ce5242b2472228851fd6483731616aba4ef977690aa517b230a8d20da8185bb416eb34e326f30568b3963c1cf26b05d1ad8 - languageName: node - linkType: hard - "@sindresorhus/is@npm:^5.2.0": version: 5.6.0 resolution: "@sindresorhus/is@npm:5.6.0" @@ -8411,7 +8347,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10c0/3948088654f3eeb45363f1db158354fb013b362dba2a5c2c18c559484d5eb9f6fd85b23d66c0a7c2fcfab7308d0a585b14dadaca6cc8bf89ebfdc7f8f5102fb7 @@ -8427,7 +8363,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-reports@npm:^3.0.0, @types/istanbul-reports@npm:^3.0.4": +"@types/istanbul-reports@npm:^3.0.0": version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: @@ -8436,13 +8372,13 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^30.0.0": - version: 30.0.0 - resolution: "@types/jest@npm:30.0.0" +"@types/jest@npm:^29.5.14": + version: 29.5.14 + resolution: "@types/jest@npm:29.5.14" dependencies: - expect: "npm:^30.0.0" - pretty-format: "npm:^30.0.0" - checksum: 10c0/20c6ce574154bc16f8dd6a97afacca4b8c4921a819496a3970382031c509ebe87a1b37b152a1b8475089b82d8ca951a9e95beb4b9bf78fbf579b1536f0b65969 + expect: "npm:^29.0.0" + pretty-format: "npm:^29.0.0" + checksum: 10c0/18e0712d818890db8a8dab3d91e9ea9f7f19e3f83c2e50b312f557017dc81466207a71f3ed79cf4428e813ba939954fa26ffa0a9a7f153181ba174581b1c2aed languageName: node linkType: hard @@ -8786,7 +8722,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.0": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10c0/1f4658385ae936330581bcb8aa3a066df03867d90281cdf89cc356d404bd6579be0f11902304e1f775d92df22c6dd761d4451c804b0a4fba973e06211e9bd77c @@ -8873,15 +8809,6 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.33": - version: 17.0.35 - resolution: "@types/yargs@npm:17.0.35" - dependencies: - "@types/yargs-parser": "npm:*" - checksum: 10c0/609557826a6b85e73ccf587923f6429850d6dc70e420b455bab4601b670bfadf684b09ae288bccedab042c48ba65f1666133cf375814204b544009f57d6eef63 - languageName: node - linkType: hard - "@types/yargs@npm:^17.0.8": version: 17.0.33 resolution: "@types/yargs@npm:17.0.33" @@ -9883,7 +9810,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": +"ansi-styles@npm:^5.0.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10c0/9c4ca80eb3c2fb7b33841c210d2f20807f40865d27008d7c3f707b7f95cab7d67462a565e2388ac3285b71cb3d9bb2173de8da37c57692a362885ec34d6e27df @@ -9936,7 +9863,7 @@ __metadata: "@swc/cli": "npm:^0.6.0" "@swc/core": "npm:^1.10.7" "@types/express": "npm:^5.0.0" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/node": "npm:^22.10.7" "@types/supertest": "npm:^6.0.2" axios: "npm:^1.13.2" @@ -11066,13 +10993,6 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^4.2.0": - version: 4.4.0 - resolution: "ci-info@npm:4.4.0" - checksum: 10c0/44156201545b8dde01aa8a09ee2fe9fc7a73b1bef9adbd4606c9f61c8caeeb73fb7a575c88b0443f7b4edb5ee45debaa59ed54ba5f99698339393ca01349eb3a - languageName: node - linkType: hard - "cjs-module-lexer@npm:^1.0.0": version: 1.4.3 resolution: "cjs-module-lexer@npm:1.4.3" @@ -13654,7 +13574,7 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.7.0": +"expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" dependencies: @@ -13667,20 +13587,6 @@ __metadata: languageName: node linkType: hard -"expect@npm:^30.0.0": - version: 30.4.1 - resolution: "expect@npm:30.4.1" - dependencies: - "@jest/expect-utils": "npm:30.4.1" - "@jest/get-type": "npm:30.1.0" - jest-matcher-utils: "npm:30.4.1" - jest-message-util: "npm:30.4.1" - jest-mock: "npm:30.4.1" - jest-util: "npm:30.4.1" - checksum: 10c0/ad04fbdffac5a2bae186478938a60f737e3aac823db9a80c87f3f390f9f458bddcc454dc3a3997d715706747c6aff928923e6a71db3a221adb89a51cc1582e72 - languageName: node - linkType: hard - "exponential-backoff@npm:^3.1.1": version: 3.1.2 resolution: "exponential-backoff@npm:3.1.2" @@ -16308,18 +16214,6 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:30.4.1": - version: 30.4.1 - resolution: "jest-diff@npm:30.4.1" - dependencies: - "@jest/diff-sequences": "npm:30.4.0" - "@jest/get-type": "npm:30.1.0" - chalk: "npm:^4.1.2" - pretty-format: "npm:30.4.1" - checksum: 10c0/787e11f0ea27e94815479d6c5415e4173da1e74bede34c1515b8515fc9d1fe053e2ad25a3c31f9998a7292c186a0e4d395ed82e0e149d57d7708ee6759b442e9 - languageName: node - linkType: hard - "jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -16408,18 +16302,6 @@ __metadata: languageName: node linkType: hard -"jest-matcher-utils@npm:30.4.1": - version: 30.4.1 - resolution: "jest-matcher-utils@npm:30.4.1" - dependencies: - "@jest/get-type": "npm:30.1.0" - chalk: "npm:^4.1.2" - jest-diff: "npm:30.4.1" - pretty-format: "npm:30.4.1" - checksum: 10c0/ddbb0c7075def27ba30160883c327cb3fd13f561f5789d00a1edca1b48b0651f8ea23a1c51bcfcb6413a68c47d658bcf47a34701b8a39ce135dd28d87a3117af - languageName: node - linkType: hard - "jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -16432,24 +16314,6 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:30.4.1": - version: 30.4.1 - resolution: "jest-message-util@npm:30.4.1" - dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@jest/types": "npm:30.4.1" - "@types/stack-utils": "npm:^2.0.3" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - jest-util: "npm:30.4.1" - picomatch: "npm:^4.0.3" - pretty-format: "npm:30.4.1" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - checksum: 10c0/ae7427544e042bc1c14abf3c0dbe8b83d0dbec22a9a5efefaca5b8ccb6b9bf391abe732e6f2117ca995c6889bfe1be35c78cec75e5ea0a50e28cffe1ba6f9fdf - languageName: node - linkType: hard - "jest-message-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" @@ -16467,17 +16331,6 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:30.4.1": - version: 30.4.1 - resolution: "jest-mock@npm:30.4.1" - dependencies: - "@jest/types": "npm:30.4.1" - "@types/node": "npm:*" - jest-util: "npm:30.4.1" - checksum: 10c0/5185a41255285c1634c5d85dda037afaaadfc12793b3293c9e253a30bb67449f8df968447f830abb9cf7a52e63694e6734680130e8085ce119056280890bf6fc - languageName: node - linkType: hard - "jest-mock@npm:^29.7.0": version: 29.7.0 resolution: "jest-mock@npm:29.7.0" @@ -16501,13 +16354,6 @@ __metadata: languageName: node linkType: hard -"jest-regex-util@npm:30.4.0": - version: 30.4.0 - resolution: "jest-regex-util@npm:30.4.0" - checksum: 10c0/fe7426f67b54d38bed8e9d6e6a099d63d72f41f5bf65b922d9d03fedcb55c614b45657207632f6ee22d0a59d8d11327891f258d23f68a58912fcdb0f7db48435 - languageName: node - linkType: hard - "jest-regex-util@npm:^29.6.3": version: 29.6.3 resolution: "jest-regex-util@npm:29.6.3" @@ -16629,20 +16475,6 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:30.4.1": - version: 30.4.1 - resolution: "jest-util@npm:30.4.1" - dependencies: - "@jest/types": "npm:30.4.1" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - graceful-fs: "npm:^4.2.11" - picomatch: "npm:^4.0.3" - checksum: 10c0/3efe1f25e5a172d04c6af8612d82867ab603b7c1bd8cb89073ff834679b44eba178793cf3af162cf5e25be13aa736ebd23a7826683acc85bddc5873f305b1f6e - languageName: node - linkType: hard - "jest-util@npm:^29.0.0, jest-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-util@npm:29.7.0" @@ -19014,13 +18846,6 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.3": - version: 4.0.4 - resolution: "picomatch@npm:4.0.4" - checksum: 10c0/e2c6023372cc7b5764719a5ffb9da0f8e781212fa7ca4bd0562db929df8e117460f00dff3cb7509dacfc06b86de924b247f504d0ce1806a37fac4633081466b0 - languageName: node - linkType: hard - "pidtree@npm:~0.6.0": version: 0.6.0 resolution: "pidtree@npm:0.6.0" @@ -19336,19 +19161,7 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:30.4.1, pretty-format@npm:^30.0.0": - version: 30.4.1 - resolution: "pretty-format@npm:30.4.1" - dependencies: - "@jest/schemas": "npm:30.4.1" - ansi-styles: "npm:^5.2.0" - react-is-18: "npm:react-is@^18.3.1" - react-is-19: "npm:react-is@^19.2.5" - checksum: 10c0/c7e6633740cd2f6d382f188c00c8b4b3f2bee3cda16db6753471c6bb4b94f76531358d3a7793062a0fb00d72ebfb934e8ae1d4f5ced6bb34c8e7f60996f90076 - languageName: node - linkType: hard - -"pretty-format@npm:^29.7.0": +"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" dependencies: @@ -19946,20 +19759,6 @@ __metadata: languageName: node linkType: hard -"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0, react-is@npm:^18.3.1": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 - languageName: node - linkType: hard - -"react-is-19@npm:react-is@^19.2.5": - version: 19.2.6 - resolution: "react-is@npm:19.2.6" - checksum: 10c0/263177f370fc156b279d22570dd6e922a0ad641a4a426a4cb70284b8003b00ef532d59f2beca1d22a1ca0b37f85f9077d7733ca5d344ebecd2942e9bc2a2a3c0 - languageName: node - linkType: hard - "react-is@npm:^16.13.1, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -19967,6 +19766,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^18.0.0, react-is@npm:^18.3.1": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10c0/f2f1e60010c683479e74c63f96b09fb41603527cd131a9959e2aee1e5a8b0caf270b365e5ca77d4a6b18aae659b60a86150bb3979073528877029b35aecd2072 + languageName: node + linkType: hard + "react-map-gl@npm:^7.0.16": version: 7.1.7 resolution: "react-map-gl@npm:7.1.7" @@ -21418,7 +21224,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.3": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: From 51d60d42d03c953aac84b43a79073fd30f540840 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:17:09 +0200 Subject: [PATCH 09/21] style(api/v2): :art: Drop accidental prettier override and format src `src/.prettierrc` was an empty object, which took precedence over the shared `@repo/prettier-config` for everything below it. Every file in src was therefore checked against Prettier defaults instead of the repo style, and all 52 of them failed that check. Removing the file lets src fall back to the shared config again. The rest of this commit is the resulting reformat: tabs, single quotes, 120 columns. No behaviour changes. Co-Authored-By: Claude Opus 5 --- apps/api-v2/src/.prettierrc | 1 - apps/api-v2/src/app.module.ts | 42 +-- .../src/common/db/external/cachet.service.ts | 115 +++---- apps/api-v2/src/common/db/prisma.service.ts | 10 +- .../decorators/api-response.decorator.ts | 143 ++++----- .../src/common/decorators/filter.decorator.ts | 99 +++--- .../common/decorators/filtered.decorator.ts | 30 +- .../decorators/optional-auth.decorator.ts | 4 +- .../common/decorators/paginated.decorator.ts | 52 ++-- .../common/decorators/pagination.decorator.ts | 66 ++-- .../common/decorators/skip-auth.decorator.ts | 4 +- .../common/decorators/sortable.decorator.ts | 60 ++-- .../common/decorators/sorting.decorator.ts | 75 ++--- .../src/common/dto/error-response.dto.ts | 22 +- .../src/common/dto/paginated-response.dto.ts | 34 +-- apps/api-v2/src/common/dto/response.dto.ts | 14 +- apps/api-v2/src/common/guards/auth.guard.ts | 117 ++++---- .../common/interceptors/error.interceptor.ts | 122 +++----- .../interceptors/response.interceptor.ts | 64 ++-- apps/api-v2/src/main.ts | 116 +++---- .../applications/applications.controller.ts | 255 +++++++--------- .../applications/applications.module.ts | 12 +- .../applications/applications.service.ts | 283 +++++++++--------- .../applications/dto/application.dto.ts | 115 ++++--- .../dto/create.application.dto.ts | 146 +++++---- .../dto/review.application.dto.ts | 108 ++++--- .../questions/application-questions.module.ts | 12 +- .../questions/dto/application-question.dto.ts | 136 ++++----- .../src/sections/auth/auth.controller.ts | 86 +++--- apps/api-v2/src/sections/auth/auth.module.ts | 38 +-- apps/api-v2/src/sections/auth/auth.service.ts | 90 +++--- .../auth/dto/accessTokenResponse.dto.ts | 6 +- .../sections/auth/dto/buildTeamProfile.dto.ts | 20 +- .../auth/dto/generateAccessToken.dto.ts | 16 +- .../src/sections/claims/claims.controller.ts | 102 +++---- .../src/sections/claims/claims.module.ts | 12 +- .../src/sections/claims/claims.service.ts | 62 ++-- .../src/sections/claims/dto/claim.dto.ts | 48 +-- .../sections/status/dto/globalStatus.dto.ts | 10 +- .../src/sections/status/dto/incident.dto.ts | 34 +-- .../status/dto/statusComponent.dto.ts | 34 +-- .../src/sections/status/status.controller.ts | 153 +++++----- .../src/sections/status/status.module.ts | 16 +- .../src/sections/status/status.service.ts | 96 +++--- .../src/sections/utility/dto/health.dto.ts | 10 +- .../src/sections/utility/dto/version.dto.ts | 14 +- .../sections/utility/utility.controller.ts | 134 ++++----- .../src/sections/utility/utility.module.ts | 10 +- .../src/sections/utility/utility.service.ts | 2 +- apps/api-v2/src/typings/express.d.ts | 12 +- apps/api-v2/src/typings/index.ts | 26 +- 51 files changed, 1544 insertions(+), 1744 deletions(-) delete mode 100644 apps/api-v2/src/.prettierrc diff --git a/apps/api-v2/src/.prettierrc b/apps/api-v2/src/.prettierrc deleted file mode 100644 index 0967ef42..00000000 --- a/apps/api-v2/src/.prettierrc +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/apps/api-v2/src/app.module.ts b/apps/api-v2/src/app.module.ts index 4a4d2040..16bf0027 100644 --- a/apps/api-v2/src/app.module.ts +++ b/apps/api-v2/src/app.module.ts @@ -1,25 +1,25 @@ -import { Module } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; -import { APP_GUARD } from "@nestjs/core"; -import { PrismaService } from "./common/db/prisma.service"; -import { AuthGuard } from "./common/guards/auth.guard"; -import { ApplicationsModule } from "./sections/applications/applications.module"; -import { AuthModule } from "./sections/auth/auth.module"; -import { StatusModule } from "./sections/status/status.module"; -import { UtilityModule } from "./sections/utility/utility.module"; -import { ClaimsModule } from "./sections/claims/claims.module"; -import { ApplicationQuestionsModule } from "./sections/applications/questions/application-questions.module"; +import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { APP_GUARD } from '@nestjs/core'; +import { PrismaService } from './common/db/prisma.service'; +import { AuthGuard } from './common/guards/auth.guard'; +import { ApplicationsModule } from './sections/applications/applications.module'; +import { AuthModule } from './sections/auth/auth.module'; +import { StatusModule } from './sections/status/status.module'; +import { UtilityModule } from './sections/utility/utility.module'; +import { ClaimsModule } from './sections/claims/claims.module'; +import { ApplicationQuestionsModule } from './sections/applications/questions/application-questions.module'; @Module({ - imports: [ - ApplicationQuestionsModule, - ApplicationsModule, - AuthModule, - ClaimsModule, - ConfigModule.forRoot({ isGlobal: true, cache: true }), - StatusModule, - UtilityModule, - ], - providers: [PrismaService, { provide: APP_GUARD, useClass: AuthGuard }], + imports: [ + ApplicationQuestionsModule, + ApplicationsModule, + AuthModule, + ClaimsModule, + ConfigModule.forRoot({ isGlobal: true, cache: true }), + StatusModule, + UtilityModule, + ], + providers: [PrismaService, { provide: APP_GUARD, useClass: AuthGuard }], }) export class AppModule {} diff --git a/apps/api-v2/src/common/db/external/cachet.service.ts b/apps/api-v2/src/common/db/external/cachet.service.ts index c7fcb292..5903afea 100644 --- a/apps/api-v2/src/common/db/external/cachet.service.ts +++ b/apps/api-v2/src/common/db/external/cachet.service.ts @@ -1,75 +1,54 @@ -import { HttpService } from "@nestjs/axios"; -import { Injectable, Logger } from "@nestjs/common"; -import { ConfigService } from "@nestjs/config"; -import { firstValueFrom } from "rxjs"; +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; /** * */ @Injectable() export class CachetAPIService { - private readonly logger = new Logger(CachetAPIService.name); - private baseURL: string | undefined; - private apiToken: string | undefined; - - constructor( - private readonly http: HttpService, - private configService: ConfigService, - ) { - if ( - !this.configService.get("CACHET_URL") || - !this.configService.get("CACHET_TOKEN") - ) { - this.logger.warn( - "Cachet configuration is missing. CachetAPIService will not function properly.", - ); - } else { - this.baseURL = this.configService.get("CACHET_URL") as string; - this.apiToken = this.configService.get("CACHET_TOKEN") as string; - - this.testConnection().then(() => { - this.logger.log("Cachet API is reachable"); - }); - } - } - - async testConnection(): Promise { - return (await firstValueFrom(this.http.get(`${this.baseURL}/api/ping`))) - .data.data; - } - - async getGlobalStatus(): Promise<{ status: string; message: string }> { - return (await firstValueFrom(this.http.get(`${this.baseURL}/api/status`))) - .data.data; - } - - async getComponents({ - status, - }: { - status?: 1 | 2 | 3 | 4 | 5 | 6; - }): Promise { - return ( - await firstValueFrom( - this.http.get( - `${this.baseURL}/api/components?per_page=30${status ? `&filter%5Bstatus%5D=${status}` : ""}`, - ), - ) - ).data.data; - } - - async getComponentGroups(): Promise { - return ( - await firstValueFrom( - this.http.get(`${this.baseURL}/api/component-groups`), - ) - ).data.data; - } - - async getIncidents(): Promise { - return ( - await firstValueFrom( - this.http.get(`${this.baseURL}/api/incidents?per_page=30`), - ) - ).data.data; - } + private readonly logger = new Logger(CachetAPIService.name); + private baseURL: string | undefined; + private apiToken: string | undefined; + + constructor( + private readonly http: HttpService, + private configService: ConfigService, + ) { + if (!this.configService.get('CACHET_URL') || !this.configService.get('CACHET_TOKEN')) { + this.logger.warn('Cachet configuration is missing. CachetAPIService will not function properly.'); + } else { + this.baseURL = this.configService.get('CACHET_URL') as string; + this.apiToken = this.configService.get('CACHET_TOKEN') as string; + + this.testConnection().then(() => { + this.logger.log('Cachet API is reachable'); + }); + } + } + + async testConnection(): Promise { + return (await firstValueFrom(this.http.get(`${this.baseURL}/api/ping`))).data.data; + } + + async getGlobalStatus(): Promise<{ status: string; message: string }> { + return (await firstValueFrom(this.http.get(`${this.baseURL}/api/status`))).data.data; + } + + async getComponents({ status }: { status?: 1 | 2 | 3 | 4 | 5 | 6 }): Promise { + return ( + await firstValueFrom( + this.http.get(`${this.baseURL}/api/components?per_page=30${status ? `&filter%5Bstatus%5D=${status}` : ''}`), + ) + ).data.data; + } + + async getComponentGroups(): Promise { + return (await firstValueFrom(this.http.get(`${this.baseURL}/api/component-groups`))).data.data; + } + + async getIncidents(): Promise { + return (await firstValueFrom(this.http.get(`${this.baseURL}/api/incidents?per_page=30`))).data.data; + } } diff --git a/apps/api-v2/src/common/db/prisma.service.ts b/apps/api-v2/src/common/db/prisma.service.ts index 4911e409..6c68e2f9 100644 --- a/apps/api-v2/src/common/db/prisma.service.ts +++ b/apps/api-v2/src/common/db/prisma.service.ts @@ -1,5 +1,5 @@ -import { Injectable, OnModuleInit } from "@nestjs/common"; -import { PrismaClient } from "@repo/db"; +import { Injectable, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@repo/db'; /** * PrismaService is a wrapper around PrismaClient that handles connection management. @@ -7,7 +7,7 @@ import { PrismaClient } from "@repo/db"; */ @Injectable() export class PrismaService extends PrismaClient implements OnModuleInit { - async onModuleInit() { - await this.$connect(); - } + async onModuleInit() { + await this.$connect(); + } } diff --git a/apps/api-v2/src/common/decorators/api-response.decorator.ts b/apps/api-v2/src/common/decorators/api-response.decorator.ts index 14f29045..0df2c8ab 100644 --- a/apps/api-v2/src/common/decorators/api-response.decorator.ts +++ b/apps/api-v2/src/common/decorators/api-response.decorator.ts @@ -1,14 +1,8 @@ -import { applyDecorators, Type } from "@nestjs/common"; -import { - ApiExtraModels, - ApiOkResponse, - ApiResponse, - ApiResponseOptions, - getSchemaPath, -} from "@nestjs/swagger"; -import { ErrorResponseDto } from "../dto/error-response.dto"; -import { PaginatedResponseDto } from "../dto/paginated-response.dto"; -import { ResponseDto } from "../dto/response.dto"; +import { applyDecorators, Type } from '@nestjs/common'; +import { ApiExtraModels, ApiOkResponse, ApiResponse, ApiResponseOptions, getSchemaPath } from '@nestjs/swagger'; +import { ErrorResponseDto } from '../dto/error-response.dto'; +import { PaginatedResponseDto } from '../dto/paginated-response.dto'; +import { ResponseDto } from '../dto/response.dto'; /** * A decorator that generates a default API response schema. * It combines the ResponseDto with a model schema for the response data. @@ -17,28 +11,26 @@ import { ResponseDto } from "../dto/response.dto"; * @returns a merged schema for the response including the model of the response data. */ export function ApiDefaultResponse>( - model: TModel, - { isArray, ...options }: ApiResponseOptions & { isArray?: boolean } = {}, + model: TModel, + { isArray, ...options }: ApiResponseOptions & { isArray?: boolean } = {}, ) { - return applyDecorators( - ApiExtraModels(ResponseDto, model), - ApiOkResponse({ - description: "Success", - ...options, - schema: { - allOf: [ - { $ref: getSchemaPath(ResponseDto) }, - { - properties: { - data: isArray - ? { type: "array", items: { $ref: getSchemaPath(model) } } - : { $ref: getSchemaPath(model) }, - }, - }, - ], - }, - }), - ); + return applyDecorators( + ApiExtraModels(ResponseDto, model), + ApiOkResponse({ + description: 'Success', + ...options, + schema: { + allOf: [ + { $ref: getSchemaPath(ResponseDto) }, + { + properties: { + data: isArray ? { type: 'array', items: { $ref: getSchemaPath(model) } } : { $ref: getSchemaPath(model) }, + }, + }, + ], + }, + }), + ); } /** @@ -48,30 +40,27 @@ export function ApiDefaultResponse>( * @param options Further options for the documentation. * @returns a merged schema for the paginated response including the model of the response data. */ -export function ApiPaginatedResponseDto>( - model: TModel, - options: ApiResponseOptions = {}, -) { - return applyDecorators( - ApiExtraModels(PaginatedResponseDto, model), - ApiOkResponse({ - description: "Success", - ...options, - schema: { - allOf: [ - { $ref: getSchemaPath(PaginatedResponseDto) }, - { - properties: { - data: { - type: "array", - items: { $ref: getSchemaPath(model) }, - }, - }, - }, - ], - }, - }), - ); +export function ApiPaginatedResponseDto>(model: TModel, options: ApiResponseOptions = {}) { + return applyDecorators( + ApiExtraModels(PaginatedResponseDto, model), + ApiOkResponse({ + description: 'Success', + ...options, + schema: { + allOf: [ + { $ref: getSchemaPath(PaginatedResponseDto) }, + { + properties: { + data: { + type: 'array', + items: { $ref: getSchemaPath(model) }, + }, + }, + }, + ], + }, + }), + ); } /** @@ -83,26 +72,26 @@ export function ApiPaginatedResponseDto>( * @returns a schema for the error response including the ErrorResponseDto. */ export function ApiErrorResponse({ - status = 500, - description = "Error: Internal Server Error", + status = 500, + description = 'Error: Internal Server Error', }: { status?: number; description?: string } = {}) { - return applyDecorators( - ApiExtraModels(ErrorResponseDto), - ApiResponse({ - status, - description, - content: { - "application/json": { - schema: { $ref: getSchemaPath(ErrorResponseDto) }, - example: { - status, - timestamp: "2025-01-01T00:00:00.000Z", - path: "/", - error: description, - message: description, - }, - }, - }, - }), - ); + return applyDecorators( + ApiExtraModels(ErrorResponseDto), + ApiResponse({ + status, + description, + content: { + 'application/json': { + schema: { $ref: getSchemaPath(ErrorResponseDto) }, + example: { + status, + timestamp: '2025-01-01T00:00:00.000Z', + path: '/', + error: description, + message: description, + }, + }, + }, + }), + ); } diff --git a/apps/api-v2/src/common/decorators/filter.decorator.ts b/apps/api-v2/src/common/decorators/filter.decorator.ts index 32c835da..8bfe2492 100644 --- a/apps/api-v2/src/common/decorators/filter.decorator.ts +++ b/apps/api-v2/src/common/decorators/filter.decorator.ts @@ -1,59 +1,56 @@ -import { createParamDecorator, ExecutionContext } from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; -import { Request } from "express"; -import { FILTER_META, FilteredOptions } from "./filtered.decorator"; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { FILTER_META, FilteredOptions } from './filtered.decorator'; // T is a union of string keys, e.g. 'name' | 'age' export interface FilterParams { - filter: { [K in T]?: any }; + filter: { [K in T]?: any }; } /** * Decorator to extract filtering parameters from the request. */ -export const Filter = createParamDecorator( - (data: unknown, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - const reflector = new Reflector(); - const handler = ctx.getHandler(); - - const filterMeta: FilteredOptions = - reflector.get(FILTER_META, handler) || {}; - const { fields } = filterMeta; - - const query = request.query; - - if (!fields || fields.length === 0) { - return { filter: {} }; - } - - const filter: FilterParams["filter"] = {}; - - fields.forEach((field) => { - const fieldName = field.name; - const value = query[fieldName] as string | undefined; - if (value !== undefined) { - // Type validation - switch (field.type) { - case Number: - if (!isNaN(Number(value))) { - filter[fieldName] = Number(value); - } - break; - case Boolean: - if (value === "true" || value === "false") { - filter[fieldName] = value === "true"; - } - break; - case String: - filter[fieldName] = String(value); - break; - default: - filter[fieldName] = value; - } - } - }); - - return { filter }; - }, -); +export const Filter = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const reflector = new Reflector(); + const handler = ctx.getHandler(); + + const filterMeta: FilteredOptions = reflector.get(FILTER_META, handler) || {}; + const { fields } = filterMeta; + + const query = request.query; + + if (!fields || fields.length === 0) { + return { filter: {} }; + } + + const filter: FilterParams['filter'] = {}; + + fields.forEach((field) => { + const fieldName = field.name; + const value = query[fieldName] as string | undefined; + if (value !== undefined) { + // Type validation + switch (field.type) { + case Number: + if (!isNaN(Number(value))) { + filter[fieldName] = Number(value); + } + break; + case Boolean: + if (value === 'true' || value === 'false') { + filter[fieldName] = value === 'true'; + } + break; + case String: + filter[fieldName] = String(value); + break; + default: + filter[fieldName] = value; + } + } + }); + + return { filter }; +}); diff --git a/apps/api-v2/src/common/decorators/filtered.decorator.ts b/apps/api-v2/src/common/decorators/filtered.decorator.ts index cb3e5147..79e58658 100644 --- a/apps/api-v2/src/common/decorators/filtered.decorator.ts +++ b/apps/api-v2/src/common/decorators/filtered.decorator.ts @@ -1,10 +1,10 @@ -import { applyDecorators, SetMetadata } from "@nestjs/common"; -import { ApiQuery, ApiQueryMetadata } from "@nestjs/swagger"; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiQuery, ApiQueryMetadata } from '@nestjs/swagger'; -export const FILTER_META = "filterMeta"; +export const FILTER_META = 'filterMeta'; export interface FilteredOptions { - fields: (Omit & { name: string })[]; + fields: (Omit & { name: string })[]; } /** @@ -13,16 +13,16 @@ export interface FilteredOptions { * @returns Decorator that sets filter metadata and Swagger API query parameters */ export function Filtered(options: FilteredOptions) { - const { fields } = options; + const { fields } = options; - return applyDecorators( - SetMetadata(FILTER_META, options), - ...(fields || []).map((field) => - ApiQuery({ - nullable: true, - ...field, - description: `Filter by ${field.name}`, - }), - ), - ); + return applyDecorators( + SetMetadata(FILTER_META, options), + ...(fields || []).map((field) => + ApiQuery({ + nullable: true, + ...field, + description: `Filter by ${field.name}`, + }), + ), + ); } diff --git a/apps/api-v2/src/common/decorators/optional-auth.decorator.ts b/apps/api-v2/src/common/decorators/optional-auth.decorator.ts index 0eba282b..c0ff899e 100644 --- a/apps/api-v2/src/common/decorators/optional-auth.decorator.ts +++ b/apps/api-v2/src/common/decorators/optional-auth.decorator.ts @@ -1,6 +1,6 @@ -import { SetMetadata } from "@nestjs/common"; +import { SetMetadata } from '@nestjs/common'; -export const IS_AUTH_OPTIONAL_KEY = "isAuthOptional"; +export const IS_AUTH_OPTIONAL_KEY = 'isAuthOptional'; /** * Decorator that makes authentication optional for a route. By default, routes diff --git a/apps/api-v2/src/common/decorators/paginated.decorator.ts b/apps/api-v2/src/common/decorators/paginated.decorator.ts index e671a04a..31e77cc4 100644 --- a/apps/api-v2/src/common/decorators/paginated.decorator.ts +++ b/apps/api-v2/src/common/decorators/paginated.decorator.ts @@ -1,12 +1,12 @@ -import { applyDecorators, SetMetadata } from "@nestjs/common"; -import { ApiQuery } from "@nestjs/swagger"; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiQuery } from '@nestjs/swagger'; -export const PAGINATION_META = "paginationMeta"; +export const PAGINATION_META = 'paginationMeta'; export interface PaginatedOptions { - defaultPage?: number; - defaultLimit?: number; - maxLimit?: number; + defaultPage?: number; + defaultLimit?: number; + maxLimit?: number; } /** @@ -15,25 +15,25 @@ export interface PaginatedOptions { * @returns Decorator that sets pagination metadata and Swagger API query parameters */ export function Paginated(options: PaginatedOptions = {}) { - const { defaultPage = 1, defaultLimit = 20, maxLimit = 100 } = options; + const { defaultPage = 1, defaultLimit = 20, maxLimit = 100 } = options; - return applyDecorators( - SetMetadata(PAGINATION_META, options), - ApiQuery({ - name: "page", - required: false, - type: Number, - example: defaultPage, - description: "Page number", - default: defaultPage, - }), - ApiQuery({ - name: "limit", - required: false, - type: Number, - example: defaultLimit, - description: `Items per page (max ${maxLimit})`, - default: defaultLimit, - }), - ); + return applyDecorators( + SetMetadata(PAGINATION_META, options), + ApiQuery({ + name: 'page', + required: false, + type: Number, + example: defaultPage, + description: 'Page number', + default: defaultPage, + }), + ApiQuery({ + name: 'limit', + required: false, + type: Number, + example: defaultLimit, + description: `Items per page (max ${maxLimit})`, + default: defaultLimit, + }), + ); } diff --git a/apps/api-v2/src/common/decorators/pagination.decorator.ts b/apps/api-v2/src/common/decorators/pagination.decorator.ts index aa0eddd7..1af04209 100644 --- a/apps/api-v2/src/common/decorators/pagination.decorator.ts +++ b/apps/api-v2/src/common/decorators/pagination.decorator.ts @@ -1,52 +1,42 @@ -import { createParamDecorator, ExecutionContext } from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; -import { Request } from "express"; -import { - PaginatedOptions, - PAGINATION_META, -} from "../decorators/paginated.decorator"; +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { PaginatedOptions, PAGINATION_META } from '../decorators/paginated.decorator'; export interface PaginationParams { - page: number; - limit: number; + page: number; + limit: number; } /** * Decorator to extract pagination parameters from the request. */ -export const Pagination = createParamDecorator( - (data: unknown, ctx: ExecutionContext) => { - const request = ctx.switchToHttp().getRequest(); - const reflector = new Reflector(); - const handler = ctx.getHandler(); +export const Pagination = createParamDecorator((data: unknown, ctx: ExecutionContext) => { + const request = ctx.switchToHttp().getRequest(); + const reflector = new Reflector(); + const handler = ctx.getHandler(); - const paginationMeta: PaginatedOptions = - reflector.get(PAGINATION_META, handler) || {}; - const { - defaultPage = 1, - defaultLimit = 20, - maxLimit = 100, - } = paginationMeta; + const paginationMeta: PaginatedOptions = reflector.get(PAGINATION_META, handler) || {}; + const { defaultPage = 1, defaultLimit = 20, maxLimit = 100 } = paginationMeta; - const { page: pageR, limit: limitR } = request.query; - let page = Number.isInteger(Number(pageR)) ? Number(pageR) : undefined; - let limit = Number.isInteger(Number(limitR)) ? Number(limitR) : undefined; + const { page: pageR, limit: limitR } = request.query; + let page = Number.isInteger(Number(pageR)) ? Number(pageR) : undefined; + let limit = Number.isInteger(Number(limitR)) ? Number(limitR) : undefined; - if (typeof page !== "number" || !Number.isInteger(page) || page < 1) { - page = defaultPage; - } - if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1) { - limit = defaultLimit; - } - if (limit > maxLimit) { - limit = maxLimit; - } + if (typeof page !== 'number' || !Number.isInteger(page) || page < 1) { + page = defaultPage; + } + if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1) { + limit = defaultLimit; + } + if (limit > maxLimit) { + limit = maxLimit; + } - return { page, limit }; - }, -); + return { page, limit }; +}); export interface PaginationParams { - page: number; - limit: number; + page: number; + limit: number; } diff --git a/apps/api-v2/src/common/decorators/skip-auth.decorator.ts b/apps/api-v2/src/common/decorators/skip-auth.decorator.ts index 098bb5a2..ba3aca33 100644 --- a/apps/api-v2/src/common/decorators/skip-auth.decorator.ts +++ b/apps/api-v2/src/common/decorators/skip-auth.decorator.ts @@ -1,4 +1,4 @@ -import { SetMetadata } from "@nestjs/common"; +import { SetMetadata } from '@nestjs/common'; /* * Decorator that marks a route as public, meaning it should skip authentication. @@ -10,5 +10,5 @@ import { SetMetadata } from "@nestjs/common"; * return { message: 'This is public data' }; * } */ -export const IS_PUBLIC_KEY = "shouldSkipAuth"; +export const IS_PUBLIC_KEY = 'shouldSkipAuth'; export const SkipAuth = () => SetMetadata(IS_PUBLIC_KEY, true); diff --git a/apps/api-v2/src/common/decorators/sortable.decorator.ts b/apps/api-v2/src/common/decorators/sortable.decorator.ts index c3dc679c..86be5335 100644 --- a/apps/api-v2/src/common/decorators/sortable.decorator.ts +++ b/apps/api-v2/src/common/decorators/sortable.decorator.ts @@ -1,41 +1,37 @@ -import { applyDecorators, SetMetadata } from "@nestjs/common"; -import { ApiQuery } from "@nestjs/swagger"; +import { applyDecorators, SetMetadata } from '@nestjs/common'; +import { ApiQuery } from '@nestjs/swagger'; -export const SORTING_META = "sortingMeta"; +export const SORTING_META = 'sortingMeta'; export interface SortableOptions { - defaultSortBy?: string; - allowedFields?: string[]; - defaultOrder?: "asc" | "desc"; + defaultSortBy?: string; + allowedFields?: string[]; + defaultOrder?: 'asc' | 'desc'; } export function Sortable(options: SortableOptions = {}) { - const { - defaultSortBy = undefined, - allowedFields = undefined, - defaultOrder = "asc", - } = options; + const { defaultSortBy = undefined, allowedFields = undefined, defaultOrder = 'asc' } = options; - const decorators = [ - SetMetadata(SORTING_META, options), - ApiQuery({ - name: "sortBy", - required: false, - type: String, - enum: allowedFields, - example: defaultSortBy, - description: "Field to sort by", - ...(defaultSortBy && { default: defaultSortBy }), - }), - ApiQuery({ - name: "order", - required: false, - enum: ["asc", "desc"], - example: defaultOrder, - description: "Sort order", - default: defaultOrder, - }), - ]; + const decorators = [ + SetMetadata(SORTING_META, options), + ApiQuery({ + name: 'sortBy', + required: false, + type: String, + enum: allowedFields, + example: defaultSortBy, + description: 'Field to sort by', + ...(defaultSortBy && { default: defaultSortBy }), + }), + ApiQuery({ + name: 'order', + required: false, + enum: ['asc', 'desc'], + example: defaultOrder, + description: 'Sort order', + default: defaultOrder, + }), + ]; - return applyDecorators(...decorators); + return applyDecorators(...decorators); } diff --git a/apps/api-v2/src/common/decorators/sorting.decorator.ts b/apps/api-v2/src/common/decorators/sorting.decorator.ts index 1f1a705a..b29795c8 100644 --- a/apps/api-v2/src/common/decorators/sorting.decorator.ts +++ b/apps/api-v2/src/common/decorators/sorting.decorator.ts @@ -1,60 +1,43 @@ -import { - BadRequestException, - createParamDecorator, - ExecutionContext, -} from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; -import { Request } from "express"; -import { SortableOptions, SORTING_META } from "./sortable.decorator"; +import { BadRequestException, createParamDecorator, ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { Request } from 'express'; +import { SortableOptions, SORTING_META } from './sortable.decorator'; export interface SortingParams { - sortBy?: T; - order?: "asc" | "desc"; + sortBy?: T; + order?: 'asc' | 'desc'; } /** * Decorator to extract sorting parameters from the request. */ export const Sorting = createParamDecorator( - ( - data: unknown, - ctx: ExecutionContext, - ): { sortBy?: string; order?: "asc" | "desc" } => { - const request = ctx.switchToHttp().getRequest(); - const reflector = new Reflector(); - const handler = ctx.getHandler(); + (data: unknown, ctx: ExecutionContext): { sortBy?: string; order?: 'asc' | 'desc' } => { + const request = ctx.switchToHttp().getRequest(); + const reflector = new Reflector(); + const handler = ctx.getHandler(); - const sortingMeta: SortableOptions = - reflector.get(SORTING_META, handler) || {}; - const { - allowedFields, - defaultOrder = "asc", - defaultSortBy = sortingMeta.allowedFields?.[0], - } = sortingMeta; + const sortingMeta: SortableOptions = reflector.get(SORTING_META, handler) || {}; + const { allowedFields, defaultOrder = 'asc', defaultSortBy = sortingMeta.allowedFields?.[0] } = sortingMeta; - let { sortBy, order } = request.query as { - sortBy?: string; - order?: "asc" | "desc"; - }; - if ( - (typeof sortBy !== "string" && typeof sortBy !== "undefined") || - (typeof sortBy === "string" && !allowedFields?.includes(sortBy)) - ) { - throw new BadRequestException( - `Invalid sortBy field: ${sortBy}. Allowed fields are: ${allowedFields?.join( - ", ", - )}`, - ); - } + let { sortBy, order } = request.query as { + sortBy?: string; + order?: 'asc' | 'desc'; + }; + if ( + (typeof sortBy !== 'string' && typeof sortBy !== 'undefined') || + (typeof sortBy === 'string' && !allowedFields?.includes(sortBy)) + ) { + throw new BadRequestException( + `Invalid sortBy field: ${sortBy}. Allowed fields are: ${allowedFields?.join(', ')}`, + ); + } - // cast the type of sortby to a value from allowedFields - sortBy = typeof sortBy === "string" ? sortBy : defaultSortBy; + // cast the type of sortby to a value from allowedFields + sortBy = typeof sortBy === 'string' ? sortBy : defaultSortBy; - order = - typeof order === "string" && ["asc", "desc"].includes(order) - ? order - : defaultOrder; + order = typeof order === 'string' && ['asc', 'desc'].includes(order) ? order : defaultOrder; - return { sortBy, order }; - }, + return { sortBy, order }; + }, ); diff --git a/apps/api-v2/src/common/dto/error-response.dto.ts b/apps/api-v2/src/common/dto/error-response.dto.ts index 455f7aca..e52b2cc9 100644 --- a/apps/api-v2/src/common/dto/error-response.dto.ts +++ b/apps/api-v2/src/common/dto/error-response.dto.ts @@ -1,18 +1,18 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty } from '@nestjs/swagger'; export class ErrorResponseDto { - @ApiProperty({ example: 500 }) - status: number; + @ApiProperty({ example: 500 }) + status: number; - @ApiProperty({ example: "2025-07-12T12:41:27.871Z" }) - timestamp: string; + @ApiProperty({ example: '2025-07-12T12:41:27.871Z' }) + timestamp: string; - @ApiProperty({ example: "/" }) - path: string; + @ApiProperty({ example: '/' }) + path: string; - @ApiProperty({ example: "Internal Server Error" }) - error: string; + @ApiProperty({ example: 'Internal Server Error' }) + error: string; - @ApiProperty({ example: "Internal Server Error" }) - message: string; + @ApiProperty({ example: 'Internal Server Error' }) + message: string; } diff --git a/apps/api-v2/src/common/dto/paginated-response.dto.ts b/apps/api-v2/src/common/dto/paginated-response.dto.ts index cd516604..ac4e33a9 100644 --- a/apps/api-v2/src/common/dto/paginated-response.dto.ts +++ b/apps/api-v2/src/common/dto/paginated-response.dto.ts @@ -1,29 +1,29 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty } from '@nestjs/swagger'; class PaginatedMetaDto { - @ApiProperty({ example: 1 }) - page: number; + @ApiProperty({ example: 1 }) + page: number; - @ApiProperty({ example: 10 }) - perPage: number; + @ApiProperty({ example: 10 }) + perPage: number; - @ApiProperty({ example: 100 }) - totalItems: number; + @ApiProperty({ example: 100 }) + totalItems: number; - @ApiProperty({ example: 10 }) - totalPages: number; + @ApiProperty({ example: 10 }) + totalPages: number; } export class PaginatedResponseDto { - @ApiProperty({ example: 200 }) - status: number; + @ApiProperty({ example: 200 }) + status: number; - @ApiProperty({ example: "Success" }) - message: string; + @ApiProperty({ example: 'Success' }) + message: string; - @ApiProperty({ isArray: true }) - data: T[]; + @ApiProperty({ isArray: true }) + data: T[]; - @ApiProperty({ type: PaginatedMetaDto }) - meta: PaginatedMetaDto; + @ApiProperty({ type: PaginatedMetaDto }) + meta: PaginatedMetaDto; } diff --git a/apps/api-v2/src/common/dto/response.dto.ts b/apps/api-v2/src/common/dto/response.dto.ts index db52c35d..190af5c2 100644 --- a/apps/api-v2/src/common/dto/response.dto.ts +++ b/apps/api-v2/src/common/dto/response.dto.ts @@ -1,13 +1,13 @@ // src/common/dto/response.dto.ts -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty } from '@nestjs/swagger'; export class ResponseDto { - @ApiProperty({ example: 200 }) - status: number; + @ApiProperty({ example: 200 }) + status: number; - @ApiProperty({ example: "Success" }) - message: string; + @ApiProperty({ example: 'Success' }) + message: string; - @ApiProperty() - data: T; + @ApiProperty() + data: T; } diff --git a/apps/api-v2/src/common/guards/auth.guard.ts b/apps/api-v2/src/common/guards/auth.guard.ts index 1e3bc372..85180f14 100644 --- a/apps/api-v2/src/common/guards/auth.guard.ts +++ b/apps/api-v2/src/common/guards/auth.guard.ts @@ -1,71 +1,66 @@ -import { - CanActivate, - ExecutionContext, - Injectable, - UnauthorizedException, -} from "@nestjs/common"; -import { Reflector } from "@nestjs/core"; -import { JwtService } from "@nestjs/jwt"; -import { Request } from "express"; -import { IS_PUBLIC_KEY } from "../decorators/skip-auth.decorator"; -import { IS_AUTH_OPTIONAL_KEY } from "../decorators/optional-auth.decorator"; +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Request } from 'express'; +import { IS_PUBLIC_KEY } from '../decorators/skip-auth.decorator'; +import { IS_AUTH_OPTIONAL_KEY } from '../decorators/optional-auth.decorator'; @Injectable() export class AuthGuard implements CanActivate { - constructor( - private jwtService: JwtService, - private reflector: Reflector, - ) {} + constructor( + private jwtService: JwtService, + private reflector: Reflector, + ) {} - /** - * Checks if the request has a valid JWT token in the Authorization header. - * If the route is public (decorated with @SkipAuth), it skips authentication. - * @param context Execution context of the request - * @returns true if the request is authenticated or public, false otherwise - */ - async canActivate(context: ExecutionContext): Promise { - // Check if the route is decorated with @SkipAuth, if so skip authentication check and return true - const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ - context.getHandler(), - context.getClass(), - ]); - if (isPublic) { - return true; - } + /** + * Checks if the request has a valid JWT token in the Authorization header. + * If the route is public (decorated with @SkipAuth), it skips authentication. + * @param context Execution context of the request + * @returns true if the request is authenticated or public, false otherwise + */ + async canActivate(context: ExecutionContext): Promise { + // Check if the route is decorated with @SkipAuth, if so skip authentication check and return true + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } - const isOptional = this.reflector.getAllAndOverride( - IS_AUTH_OPTIONAL_KEY, - [context.getHandler(), context.getClass()], - ); + const isOptional = this.reflector.getAllAndOverride(IS_AUTH_OPTIONAL_KEY, [ + context.getHandler(), + context.getClass(), + ]); - // If the route is not public, we proceed with authentication - const request = context.switchToHttp().getRequest(); - const token = this.extractTokenFromHeader(request); + // If the route is not public, we proceed with authentication + const request = context.switchToHttp().getRequest(); + const token = this.extractTokenFromHeader(request); - if (!token && isOptional) { - return true; - } + if (!token && isOptional) { + return true; + } - if (!token) { - throw new UnauthorizedException(); - } + if (!token) { + throw new UnauthorizedException(); + } - try { - const payload = await this.jwtService.verifyAsync(token); - request["token"] = payload; - } catch { - // If auth is optional but failed to verify, we still reject the request - throw new UnauthorizedException(); - } - return true; - } + try { + const payload = await this.jwtService.verifyAsync(token); + request['token'] = payload; + } catch { + // If auth is optional but failed to verify, we still reject the request + throw new UnauthorizedException(); + } + return true; + } - /** - * Extracts the JWT token from the Authorization header of the request. - * @param request The HTTP request object - * @returns The extracted token or undefined if not found - */ - private extractTokenFromHeader(request: Request): string | undefined { - const [type, token] = request.headers.authorization?.split(" ") ?? []; - return type === "Bearer" ? token : undefined; - } + /** + * Extracts the JWT token from the Authorization header of the request. + * @param request The HTTP request object + * @returns The extracted token or undefined if not found + */ + private extractTokenFromHeader(request: Request): string | undefined { + const [type, token] = request.headers.authorization?.split(' ') ?? []; + return type === 'Bearer' ? token : undefined; + } } diff --git a/apps/api-v2/src/common/interceptors/error.interceptor.ts b/apps/api-v2/src/common/interceptors/error.interceptor.ts index 5bd1af02..7bbd5e34 100644 --- a/apps/api-v2/src/common/interceptors/error.interceptor.ts +++ b/apps/api-v2/src/common/interceptors/error.interceptor.ts @@ -1,12 +1,5 @@ -import { - ArgumentsHost, - Catch, - ExceptionFilter, - HttpException, - HttpStatus, - Logger, -} from "@nestjs/common"; -import { AbstractHttpAdapter } from "@nestjs/core"; +import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { AbstractHttpAdapter } from '@nestjs/core'; /** * Global exception filter that handles all unhandled exceptions in the application. @@ -14,80 +7,61 @@ import { AbstractHttpAdapter } from "@nestjs/core"; */ @Catch() export class ExceptionsFilter implements ExceptionFilter { - private readonly logger = new Logger(ExceptionsFilter.name); + private readonly logger = new Logger(ExceptionsFilter.name); - constructor(private readonly httpAdapter: AbstractHttpAdapter) {} + constructor(private readonly httpAdapter: AbstractHttpAdapter) {} - catch(exception: unknown, host: ArgumentsHost): void { - const httpAdapter = this.httpAdapter; + catch(exception: unknown, host: ArgumentsHost): void { + const httpAdapter = this.httpAdapter; - const ctx = host.switchToHttp(); - const request = ctx.getRequest(); - const response = ctx.getResponse(); + const ctx = host.switchToHttp(); + const request = ctx.getRequest(); + const response = ctx.getResponse(); - const httpStatus = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + const httpStatus = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; - let message: string = "Internal Server Error"; - let error: string = "InternalServerError"; - if (exception instanceof HttpException) { - const responseObj = exception.getResponse(); - if ( - typeof responseObj === "object" && - responseObj !== null && - "message" in responseObj - ) { - const msg = (responseObj as { message?: string | string[] }).message; - message = Array.isArray(msg) - ? msg.join(", ") - : (msg ?? exception.message); - } else { - message = exception.message; - } + let message: string = 'Internal Server Error'; + let error: string = 'InternalServerError'; + if (exception instanceof HttpException) { + const responseObj = exception.getResponse(); + if (typeof responseObj === 'object' && responseObj !== null && 'message' in responseObj) { + const msg = (responseObj as { message?: string | string[] }).message; + message = Array.isArray(msg) ? msg.join(', ') : (msg ?? exception.message); + } else { + message = exception.message; + } - if ( - typeof responseObj === "object" && - responseObj !== null && - "error" in responseObj - ) { - error = (responseObj as { error?: string }).error ?? exception.name; - } else { - error = exception.name; - } - } else if (typeof exception === "object" && exception !== null) { - if ("message" in exception) { - const msg = (exception as { message?: string | string[] }).message; - message = Array.isArray(msg) - ? msg.join(", ") - : (msg ?? "Internal Server Error"); - } + if (typeof responseObj === 'object' && responseObj !== null && 'error' in responseObj) { + error = (responseObj as { error?: string }).error ?? exception.name; + } else { + error = exception.name; + } + } else if (typeof exception === 'object' && exception !== null) { + if ('message' in exception) { + const msg = (exception as { message?: string | string[] }).message; + message = Array.isArray(msg) ? msg.join(', ') : (msg ?? 'Internal Server Error'); + } - if ("name" in exception) { - error = (exception as { name?: string }).name ?? "InternalServerError"; - } - } + if ('name' in exception) { + error = (exception as { name?: string }).name ?? 'InternalServerError'; + } + } - const errorResponse = { - status: httpStatus, - timestamp: new Date().toISOString(), - path: request.url, - error: error, - message: Array.isArray(message) ? message.join(", ") : message, - }; + const errorResponse = { + status: httpStatus, + timestamp: new Date().toISOString(), + path: request.url, + error: error, + message: Array.isArray(message) ? message.join(', ') : message, + }; - // Log the error + // Log the error - if (!(exception instanceof HttpException)) { - this.logger.error( - `Unhandled Error {${request.url}, ${request.method}}: ${errorResponse.message}`, - ); - this.logger.debug( - exception instanceof Error ? exception.stack : undefined, - ); - } + if (!(exception instanceof HttpException)) { + this.logger.error(`Unhandled Error {${request.url}, ${request.method}}: ${errorResponse.message}`); + this.logger.debug(exception instanceof Error ? exception.stack : undefined); + } - httpAdapter.reply(response, errorResponse, httpStatus); - } + httpAdapter.reply(response, errorResponse, httpStatus); + } } diff --git a/apps/api-v2/src/common/interceptors/response.interceptor.ts b/apps/api-v2/src/common/interceptors/response.interceptor.ts index 46b0ca83..73fb9f7e 100644 --- a/apps/api-v2/src/common/interceptors/response.interceptor.ts +++ b/apps/api-v2/src/common/interceptors/response.interceptor.ts @@ -1,16 +1,7 @@ -import { - CallHandler, - ExecutionContext, - Injectable, - NestInterceptor, -} from "@nestjs/common"; -import { Observable } from "rxjs"; -import { map } from "rxjs/operators"; -import { - GenericControllerResponse, - PaginatedMeta, - Response, -} from "src/typings"; +import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { GenericControllerResponse, PaginatedMeta, Response } from 'src/typings'; /** * Interceptor that formats the response for all successful requests. @@ -19,31 +10,26 @@ import { */ @Injectable() export class ResponseInterceptor implements NestInterceptor> { - intercept( - context: ExecutionContext, - next: CallHandler, - ): Observable> { - return next.handle().pipe( - map((data: GenericControllerResponse) => { - const status: number = Number( - context.switchToHttp().getResponse().statusCode, - ); + intercept(context: ExecutionContext, next: CallHandler): Observable> { + return next.handle().pipe( + map((data: GenericControllerResponse) => { + const status: number = Number(context.switchToHttp().getResponse().statusCode); - if (typeof data === "object" && "data" in data && "meta" in data) { - return { - status, - message: "Success", - data: data.data as T, - meta: data.meta as PaginatedMeta, - }; - } else { - return { - status, - message: "Success", - data: data as T, - }; - } - }), - ); - } + if (typeof data === 'object' && 'data' in data && 'meta' in data) { + return { + status, + message: 'Success', + data: data.data as T, + meta: data.meta as PaginatedMeta, + }; + } else { + return { + status, + message: 'Success', + data: data as T, + }; + } + }), + ); + } } diff --git a/apps/api-v2/src/main.ts b/apps/api-v2/src/main.ts index f27c5ef2..638db3a1 100644 --- a/apps/api-v2/src/main.ts +++ b/apps/api-v2/src/main.ts @@ -1,63 +1,63 @@ -import { ValidationPipe, VersioningType } from "@nestjs/common"; -import { HttpAdapterHost, NestFactory } from "@nestjs/core"; -import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger"; -import helmet from "helmet"; -import { AppModule } from "./app.module"; -import { ExceptionsFilter } from "./common/interceptors/error.interceptor"; -import { ResponseInterceptor } from "./common/interceptors/response.interceptor"; +import { ValidationPipe, VersioningType } from '@nestjs/common'; +import { HttpAdapterHost, NestFactory } from '@nestjs/core'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; +import helmet from 'helmet'; +import { AppModule } from './app.module'; +import { ExceptionsFilter } from './common/interceptors/error.interceptor'; +import { ResponseInterceptor } from './common/interceptors/response.interceptor'; export async function bootstrap() { - const app = await NestFactory.create(AppModule); - const { httpAdapter } = app.get(HttpAdapterHost); - - app.enableShutdownHooks(); - app.enableCors(); - app.use(helmet()); - - app.enableVersioning({ - type: VersioningType.URI, - defaultVersion: "2", - }); - - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - transformOptions: { - enableImplicitConversion: true, - }, - }), - ); - - app.useGlobalFilters(new ExceptionsFilter(httpAdapter)); - app.useGlobalInterceptors(new ResponseInterceptor()); - - const config = new DocumentBuilder() - .setTitle("BuildTheEarth API") - .setDescription( - "The BuildTheEarth API is a RESTful API that provides access to the BuildTheEarth project data and services.", - ) - .setVersion("2.0") - .addBearerAuth({ - type: "http", - name: "Authorization", - in: "headers", - scheme: "bearer", - bearerFormat: "JWT", - }) - .build(); - - const documentFactory = () => SwaggerModule.createDocument(app, config); - - SwaggerModule.setup("/v2/docs", app, documentFactory, { - jsonDocumentUrl: "/v2/docs.json", - yamlDocumentUrl: "/v2/docs.yaml", - }); - - await app.listen(process.env.PORT ?? 8080); + const app = await NestFactory.create(AppModule); + const { httpAdapter } = app.get(HttpAdapterHost); + + app.enableShutdownHooks(); + app.enableCors(); + app.use(helmet()); + + app.enableVersioning({ + type: VersioningType.URI, + defaultVersion: '2', + }); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { + enableImplicitConversion: true, + }, + }), + ); + + app.useGlobalFilters(new ExceptionsFilter(httpAdapter)); + app.useGlobalInterceptors(new ResponseInterceptor()); + + const config = new DocumentBuilder() + .setTitle('BuildTheEarth API') + .setDescription( + 'The BuildTheEarth API is a RESTful API that provides access to the BuildTheEarth project data and services.', + ) + .setVersion('2.0') + .addBearerAuth({ + type: 'http', + name: 'Authorization', + in: 'headers', + scheme: 'bearer', + bearerFormat: 'JWT', + }) + .build(); + + const documentFactory = () => SwaggerModule.createDocument(app, config); + + SwaggerModule.setup('/v2/docs', app, documentFactory, { + jsonDocumentUrl: '/v2/docs.json', + yamlDocumentUrl: '/v2/docs.yaml', + }); + + await app.listen(process.env.PORT ?? 8080); } if (require.main === module) { - bootstrap(); -} \ No newline at end of file + bootstrap(); +} diff --git a/apps/api-v2/src/sections/applications/applications.controller.ts b/apps/api-v2/src/sections/applications/applications.controller.ts index c9c2a4e9..186edefc 100644 --- a/apps/api-v2/src/sections/applications/applications.controller.ts +++ b/apps/api-v2/src/sections/applications/applications.controller.ts @@ -1,148 +1,123 @@ -import { Body, Controller, Get, Post, Req, Param, Put } from "@nestjs/common"; -import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; -import { ApplicationStatus } from "@repo/db"; -import { Request } from "express"; +import { Body, Controller, Get, Post, Req, Param, Put } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { ApplicationStatus } from '@repo/db'; +import { Request } from 'express'; import { - ApiDefaultResponse, - ApiErrorResponse, - ApiPaginatedResponseDto, -} from "src/common/decorators/api-response.decorator"; -import { Filter, FilterParams } from "src/common/decorators/filter.decorator"; -import { Filtered } from "src/common/decorators/filtered.decorator"; -import { Paginated } from "src/common/decorators/paginated.decorator"; -import { - Pagination, - PaginationParams, -} from "src/common/decorators/pagination.decorator"; -import { Sortable } from "src/common/decorators/sortable.decorator"; -import { - Sorting, - SortingParams, -} from "src/common/decorators/sorting.decorator"; -import { ControllerResponse, PaginatedControllerResponse } from "src/typings"; -import { ApplicationsService } from "./applications.service"; -import { ApplicationDto } from "./dto/application.dto"; -import { CreateApplicationDto } from "./dto/create.application.dto"; -import { ReviewApplicationDto } from "./dto/review.application.dto"; + ApiDefaultResponse, + ApiErrorResponse, + ApiPaginatedResponseDto, +} from 'src/common/decorators/api-response.decorator'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { Paginated } from 'src/common/decorators/paginated.decorator'; +import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { Sortable } from 'src/common/decorators/sortable.decorator'; +import { Sorting, SortingParams } from 'src/common/decorators/sorting.decorator'; +import { ControllerResponse, PaginatedControllerResponse } from 'src/typings'; +import { ApplicationsService } from './applications.service'; +import { ApplicationDto } from './dto/application.dto'; +import { CreateApplicationDto } from './dto/create.application.dto'; +import { ReviewApplicationDto } from './dto/review.application.dto'; -@Controller("applications") +@Controller('applications') export class ApplicationsController { - constructor(private readonly applicationsService: ApplicationsService) {} + constructor(private readonly applicationsService: ApplicationsService) {} - /** - * Returns all applications of the currently authenticated team. - */ - @Get("/") - @ApiBearerAuth() - @Sortable({ - defaultSortBy: "createdAt", - allowedFields: [ - "userId", - "reviewerId", - "status", - "createdAt", - "reviewedAt", - "reason", - "claimId", - "trial", - ], - defaultOrder: "desc", - }) - @Paginated() - @ApiOperation({ - summary: "Get All Applications", - description: - "Returns all applications of the currently authenticated team.", - }) - @Filtered({ - fields: [ - { name: "userId", required: false, type: String }, - { name: "reviewerId", required: false, type: String }, - { - name: "status", - required: false, - type: String, - enum: ApplicationStatus, - }, - { name: "createdAt", required: false, type: String }, - { name: "reviewedAt", required: false, type: String }, - { name: "reason", required: false, type: String }, - { name: "claimId", required: false, type: String }, - { name: "trial", required: false, type: Boolean }, - ], - }) - @ApiPaginatedResponseDto(ApplicationDto, { description: "Success" }) - @ApiErrorResponse({ status: 401, description: "Unauthorized" }) - async getApplications( - @Pagination() pagination: PaginationParams, - @Sorting() sorting: SortingParams, - @Filter() filter: FilterParams, - @Req() req: Request, - ): PaginatedControllerResponse { - return await this.applicationsService.findAll( - pagination, - sorting.sortBy, - sorting.order, - filter.filter, - req.token.id, - ); - } + /** + * Returns all applications of the currently authenticated team. + */ + @Get('/') + @ApiBearerAuth() + @Sortable({ + defaultSortBy: 'createdAt', + allowedFields: ['userId', 'reviewerId', 'status', 'createdAt', 'reviewedAt', 'reason', 'claimId', 'trial'], + defaultOrder: 'desc', + }) + @Paginated() + @ApiOperation({ + summary: 'Get All Applications', + description: 'Returns all applications of the currently authenticated team.', + }) + @Filtered({ + fields: [ + { name: 'userId', required: false, type: String }, + { name: 'reviewerId', required: false, type: String }, + { + name: 'status', + required: false, + type: String, + enum: ApplicationStatus, + }, + { name: 'createdAt', required: false, type: String }, + { name: 'reviewedAt', required: false, type: String }, + { name: 'reason', required: false, type: String }, + { name: 'claimId', required: false, type: String }, + { name: 'trial', required: false, type: Boolean }, + ], + }) + @ApiPaginatedResponseDto(ApplicationDto, { description: 'Success' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + async getApplications( + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + @Req() req: Request, + ): PaginatedControllerResponse { + return await this.applicationsService.findAll( + pagination, + sorting.sortBy, + sorting.order, + filter.filter, + req.token.id, + ); + } - /** - * Creates a new application for the currently authenticated team. - */ - @Post("/") - @ApiBearerAuth() - @ApiOperation({ - summary: "Create Application", - description: - "Creates a new application for the currently authenticated team.", - }) - @ApiDefaultResponse(ApplicationDto, { - status: 201, - description: "Application created successfully.", - }) - @ApiErrorResponse({ status: 401, description: "Unauthorized" }) - @ApiErrorResponse({ status: 400, description: "Bad Request" }) - async createApplication( - @Body() createApplicationDto: CreateApplicationDto, - @Req() req: Request, - ): ControllerResponse { - return await this.applicationsService.create( - createApplicationDto, - req.token.id, - ); - } + /** + * Creates a new application for the currently authenticated team. + */ + @Post('/') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create Application', + description: 'Creates a new application for the currently authenticated team.', + }) + @ApiDefaultResponse(ApplicationDto, { + status: 201, + description: 'Application created successfully.', + }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + async createApplication(@Body() createApplicationDto: CreateApplicationDto, @Req() req: Request): ControllerResponse { + return await this.applicationsService.create(createApplicationDto, req.token.id); + } - @Get("/:id") - @ApiBearerAuth() - @ApiOperation({ - summary: "Get Application by ID", - description: "Returns the application with the specified ID.", - }) - @ApiDefaultResponse(ApplicationDto, { description: "Success" }) - @ApiErrorResponse({ status: 401, description: "Unauthorized" }) - @ApiErrorResponse({ status: 404, description: "Application not found" }) - async getApplicationById( - @Param("id") id: string, - ) : ControllerResponse { - return await this.applicationsService.findById(id); - } + @Get('/:id') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Get Application by ID', + description: 'Returns the application with the specified ID.', + }) + @ApiDefaultResponse(ApplicationDto, { description: 'Success' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Application not found' }) + async getApplicationById(@Param('id') id: string): ControllerResponse { + return await this.applicationsService.findById(id); + } - @Put('/:id') - @ApiBearerAuth() - @ApiOperation({ - summary: 'Review Application', - description: 'Review and update an application (set status, reason, claim, etc).', - }) - @ApiDefaultResponse(ApplicationDto, { description: 'Success' }) - @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) - @ApiErrorResponse({ status: 400, description: 'Bad Request' }) - @ApiErrorResponse({ status: 404, description: 'Application not found' }) - async reviewApplication( - @Param('id') id: string, - @Body() reviewApplicationDto: ReviewApplicationDto, - ): ControllerResponse { - return await this.applicationsService.review(id, reviewApplicationDto); - } + @Put('/:id') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Review Application', + description: 'Review and update an application (set status, reason, claim, etc).', + }) + @ApiDefaultResponse(ApplicationDto, { description: 'Success' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 404, description: 'Application not found' }) + async reviewApplication( + @Param('id') id: string, + @Body() reviewApplicationDto: ReviewApplicationDto, + ): ControllerResponse { + return await this.applicationsService.review(id, reviewApplicationDto); + } } diff --git a/apps/api-v2/src/sections/applications/applications.module.ts b/apps/api-v2/src/sections/applications/applications.module.ts index b016fffc..c8a99320 100644 --- a/apps/api-v2/src/sections/applications/applications.module.ts +++ b/apps/api-v2/src/sections/applications/applications.module.ts @@ -1,10 +1,10 @@ -import { Module } from "@nestjs/common"; -import { PrismaService } from "src/common/db/prisma.service"; -import { ApplicationsController } from "./applications.controller"; -import { ApplicationsService } from "./applications.service"; +import { Module } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ApplicationsController } from './applications.controller'; +import { ApplicationsService } from './applications.service'; @Module({ - controllers: [ApplicationsController], - providers: [ApplicationsService, PrismaService], + controllers: [ApplicationsController], + providers: [ApplicationsService, PrismaService], }) export class ApplicationsModule {} diff --git a/apps/api-v2/src/sections/applications/applications.service.ts b/apps/api-v2/src/sections/applications/applications.service.ts index b13bc40c..942d64cb 100644 --- a/apps/api-v2/src/sections/applications/applications.service.ts +++ b/apps/api-v2/src/sections/applications/applications.service.ts @@ -1,147 +1,144 @@ -import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; -import { PrismaService } from "src/common/db/prisma.service"; -import { FilterParams } from "src/common/decorators/filter.decorator"; -import { PaginationParams } from "src/common/decorators/pagination.decorator"; -import { SortingParams } from "src/common/decorators/sorting.decorator"; -import { CreateApplicationDto } from "./dto/create.application.dto"; -import { ApplicationDto } from "./dto/application.dto"; -import { randomUUID } from "crypto"; -import { ApplicationStatus } from "@repo/db"; -import { ReviewApplicationDto } from "./dto/review.application.dto"; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { FilterParams } from 'src/common/decorators/filter.decorator'; +import { PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { SortingParams } from 'src/common/decorators/sorting.decorator'; +import { CreateApplicationDto } from './dto/create.application.dto'; +import { ApplicationDto } from './dto/application.dto'; +import { randomUUID } from 'crypto'; +import { ApplicationStatus } from '@repo/db'; +import { ReviewApplicationDto } from './dto/review.application.dto'; @Injectable() export class ApplicationsService { - constructor(private readonly prisma: PrismaService) {} - - /** - * Finds all applications based on pagination, sorting, and filtering parameters. - * @param pagination - Pagination parameters. - * @param sortBy - Field to sort by. - * @param order - Order of sorting (asc/desc). - * @param filter - Filter parameters. - * @param buildteamId - ID of the build team to filter applications. - * @returns A paginated response containing the applications and metadata. - */ - async findAll( - pagination: PaginationParams, - sortBy?: SortingParams["sortBy"], - order?: SortingParams["order"], - filter?: FilterParams["filter"], - buildteamId?: string, - ) { - const sortField = sortBy || "createdAt"; - const sortOrder = order === "desc" ? "desc" : "asc"; - - const take = Math.max(Number(pagination.limit) || 20, 1); - const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; - - const combinedFilter = { - ...filter, - ...(buildteamId ? { buildteamId } : {}), - }; - - const [applications, count] = await Promise.all([ - this.prisma.application.findMany({ - where: combinedFilter, - orderBy: { [sortField]: sortOrder }, - skip, - take, - }), - this.prisma.application.count({ where: combinedFilter }), - ]); - - return { - data: applications, - meta: { - page: pagination.page, - perPage: pagination.limit, - totalItems: count, - totalPages: Math.ceil(count / pagination.limit), - }, - }; - } - - /** - * Creates a new application. - * @param createApplicationDto - Data transfer object for creating an application. - * @param buildteamId - ID of the build team to associate with the application. - * @returns The created application. - */ - async create( - createApplicationDto: CreateApplicationDto, - buildteamId: string, - ) { - // TODO inject the userService to check if the user exists - const userExists = await this.prisma.user.findUnique({ - where: { id: createApplicationDto.userId }, - }); - - if (!userExists) { - throw new BadRequestException("User does not exist"); - } - - const applicationData: ApplicationDto = { - id: randomUUID(), - buildteamId, - userId: createApplicationDto.userId, - reviewerId: createApplicationDto.reviewerId ?? null, - status: createApplicationDto.status ?? ApplicationStatus.SEND, - createdAt: new Date().toISOString(), - reviewedAt: createApplicationDto.reviewedAt ?? null, - reason: createApplicationDto.reason ?? null, - claimId: createApplicationDto.claimId ?? null, - trial: createApplicationDto.trial ?? false, - }; - - return await this.prisma.application.create({ - data: applicationData, - }); - } - - /** - * Finds an application by its ID. - * @param id - The ID of the application to find. - * @returns The application with the specified ID, or null if not found. - */ - async findById(id: string) { - const application = await this.prisma.application.findUnique({ - where: { id }, - }); - - if (!application) { - throw new NotFoundException("Application not found"); - } - - return application; - } - - /** - * Reviews an application by updating its status, reviewer, and other relevant fields. - * @param id The ID of the application to review. - * @param reviewApplicationDto The data transfer object containing the review details (status, reviewerId, reason, etc.). - * @returns The updated application. - */ - async review(id: string, reviewApplicationDto: ReviewApplicationDto) { - // TODO inject the userService to check if the reviewer exists (if reviewerId is provided) - const reviewerId = reviewApplicationDto.reviewerId ?? null; - - const status = reviewApplicationDto.status ?? ApplicationStatus.REVIEWING; - - const reviewedAt = - status !== ApplicationStatus.REVIEWING ? reviewApplicationDto.reviewedAt ?? new Date().toISOString() : null; - - const data: any = { - reviewerId, - status, - reviewedAt, - reason: reviewApplicationDto.reason ?? null, - claimId: reviewApplicationDto.claimId ?? null, - trial: reviewApplicationDto.trial ?? false, - }; - - return await this.prisma.application.update({ - where: { id }, - data, - }); - } + constructor(private readonly prisma: PrismaService) {} + + /** + * Finds all applications based on pagination, sorting, and filtering parameters. + * @param pagination - Pagination parameters. + * @param sortBy - Field to sort by. + * @param order - Order of sorting (asc/desc). + * @param filter - Filter parameters. + * @param buildteamId - ID of the build team to filter applications. + * @returns A paginated response containing the applications and metadata. + */ + async findAll( + pagination: PaginationParams, + sortBy?: SortingParams['sortBy'], + order?: SortingParams['order'], + filter?: FilterParams['filter'], + buildteamId?: string, + ) { + const sortField = sortBy || 'createdAt'; + const sortOrder = order === 'desc' ? 'desc' : 'asc'; + + const take = Math.max(Number(pagination.limit) || 20, 1); + const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; + + const combinedFilter = { + ...filter, + ...(buildteamId ? { buildteamId } : {}), + }; + + const [applications, count] = await Promise.all([ + this.prisma.application.findMany({ + where: combinedFilter, + orderBy: { [sortField]: sortOrder }, + skip, + take, + }), + this.prisma.application.count({ where: combinedFilter }), + ]); + + return { + data: applications, + meta: { + page: pagination.page, + perPage: pagination.limit, + totalItems: count, + totalPages: Math.ceil(count / pagination.limit), + }, + }; + } + + /** + * Creates a new application. + * @param createApplicationDto - Data transfer object for creating an application. + * @param buildteamId - ID of the build team to associate with the application. + * @returns The created application. + */ + async create(createApplicationDto: CreateApplicationDto, buildteamId: string) { + // TODO inject the userService to check if the user exists + const userExists = await this.prisma.user.findUnique({ + where: { id: createApplicationDto.userId }, + }); + + if (!userExists) { + throw new BadRequestException('User does not exist'); + } + + const applicationData: ApplicationDto = { + id: randomUUID(), + buildteamId, + userId: createApplicationDto.userId, + reviewerId: createApplicationDto.reviewerId ?? null, + status: createApplicationDto.status ?? ApplicationStatus.SEND, + createdAt: new Date().toISOString(), + reviewedAt: createApplicationDto.reviewedAt ?? null, + reason: createApplicationDto.reason ?? null, + claimId: createApplicationDto.claimId ?? null, + trial: createApplicationDto.trial ?? false, + }; + + return await this.prisma.application.create({ + data: applicationData, + }); + } + + /** + * Finds an application by its ID. + * @param id - The ID of the application to find. + * @returns The application with the specified ID, or null if not found. + */ + async findById(id: string) { + const application = await this.prisma.application.findUnique({ + where: { id }, + }); + + if (!application) { + throw new NotFoundException('Application not found'); + } + + return application; + } + + /** + * Reviews an application by updating its status, reviewer, and other relevant fields. + * @param id The ID of the application to review. + * @param reviewApplicationDto The data transfer object containing the review details (status, reviewerId, reason, etc.). + * @returns The updated application. + */ + async review(id: string, reviewApplicationDto: ReviewApplicationDto) { + // TODO inject the userService to check if the reviewer exists (if reviewerId is provided) + const reviewerId = reviewApplicationDto.reviewerId ?? null; + + const status = reviewApplicationDto.status ?? ApplicationStatus.REVIEWING; + + const reviewedAt = + status !== ApplicationStatus.REVIEWING ? (reviewApplicationDto.reviewedAt ?? new Date().toISOString()) : null; + + const data: any = { + reviewerId, + status, + reviewedAt, + reason: reviewApplicationDto.reason ?? null, + claimId: reviewApplicationDto.claimId ?? null, + trial: reviewApplicationDto.trial ?? false, + }; + + return await this.prisma.application.update({ + where: { id }, + data, + }); + } } diff --git a/apps/api-v2/src/sections/applications/dto/application.dto.ts b/apps/api-v2/src/sections/applications/dto/application.dto.ts index e4ab4b64..a436f688 100644 --- a/apps/api-v2/src/sections/applications/dto/application.dto.ts +++ b/apps/api-v2/src/sections/applications/dto/application.dto.ts @@ -1,70 +1,69 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { ApplicationStatus } from "@repo/db"; +import { ApiProperty } from '@nestjs/swagger'; +import { ApplicationStatus } from '@repo/db'; export class ApplicationDto { - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - description: "The unique ID of the application.", - }) - id: string; + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The unique ID of the application.', + }) + id: string; - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - description: "The ID of the build team the application is for.", - }) - buildteamId: string; + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the build team the application is for.', + }) + buildteamId: string; - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - description: "The ID of the user who submitted the application.", - }) - userId: string; + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the user who submitted the application.', + }) + userId: string; - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - nullable: true, - description: "The ID of the reviewer who handled the application, if any.", - }) - reviewerId: string | null; + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + nullable: true, + description: 'The ID of the reviewer who handled the application, if any.', + }) + reviewerId: string | null; - @ApiProperty({ - example: ApplicationStatus.ACCEPTED, - enum: ApplicationStatus, - description: "The current status of the application.", - }) - status: ApplicationStatus; + @ApiProperty({ + example: ApplicationStatus.ACCEPTED, + enum: ApplicationStatus, + description: 'The current status of the application.', + }) + status: ApplicationStatus; - @ApiProperty({ - example: "2025-04-19T16:45:18.767Z", - description: "The timestamp when the application was created.", - }) - createdAt: string; + @ApiProperty({ + example: '2025-04-19T16:45:18.767Z', + description: 'The timestamp when the application was created.', + }) + createdAt: string; - @ApiProperty({ - example: "2025-04-19T16:45:18.767Z", - nullable: true, - description: - "The timestamp when the application was reviewed, if applicable.", - }) - reviewedAt: string | null; + @ApiProperty({ + example: '2025-04-19T16:45:18.767Z', + nullable: true, + description: 'The timestamp when the application was reviewed, if applicable.', + }) + reviewedAt: string | null; - @ApiProperty({ - example: "User failed interview stage", - nullable: true, - description: "The reason for the application decision, if any.", - }) - reason: string | null; + @ApiProperty({ + example: 'User failed interview stage', + nullable: true, + description: 'The reason for the application decision, if any.', + }) + reason: string | null; - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - nullable: true, - description: "The ID of the claim associated with the application, if any.", - }) - claimId: string | null; + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + nullable: true, + description: 'The ID of the claim associated with the application, if any.', + }) + claimId: string | null; - @ApiProperty({ - example: false, - description: "Indicates whether this is a trial application.", - }) - trial: boolean; + @ApiProperty({ + example: false, + description: 'Indicates whether this is a trial application.', + }) + trial: boolean; } diff --git a/apps/api-v2/src/sections/applications/dto/create.application.dto.ts b/apps/api-v2/src/sections/applications/dto/create.application.dto.ts index d956f39d..1689649e 100644 --- a/apps/api-v2/src/sections/applications/dto/create.application.dto.ts +++ b/apps/api-v2/src/sections/applications/dto/create.application.dto.ts @@ -1,86 +1,78 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { ApplicationStatus } from "@repo/db"; -import { - IsBoolean, - IsEnum, - IsISO8601, - IsOptional, - IsString, - IsUUID, -} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApplicationStatus } from '@repo/db'; +import { IsBoolean, IsEnum, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator'; export class CreateApplicationDto { - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - description: "The ID of the user who applied.", - }) - @IsUUID() - userId: string; + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the user who applied.', + }) + @IsUUID() + userId: string; - @ApiPropertyOptional({ - example: "00000000-0000-0000-0000-000000000000", - nullable: true, - default: null, - description: "The ID of the reviewer, if any.", - }) - @IsOptional() - @IsUUID() - reviewerId?: string | null; + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + nullable: true, + default: null, + description: 'The ID of the reviewer, if any.', + }) + @IsOptional() + @IsUUID() + reviewerId?: string | null; - @ApiPropertyOptional({ - example: ApplicationStatus.ACCEPTED, - enum: ApplicationStatus, - default: ApplicationStatus.SEND, - description: "The current status of the application.", - }) - @IsOptional() - @IsEnum(ApplicationStatus) - status?: ApplicationStatus; + @ApiPropertyOptional({ + example: ApplicationStatus.ACCEPTED, + enum: ApplicationStatus, + default: ApplicationStatus.SEND, + description: 'The current status of the application.', + }) + @IsOptional() + @IsEnum(ApplicationStatus) + status?: ApplicationStatus; - @ApiPropertyOptional({ - example: "2025-04-19T16:45:18.767Z", - description: - "The time the application was created. If not provided, defaults to the current time.", - }) - @IsOptional() - @IsISO8601() - createdAt?: string; + @ApiPropertyOptional({ + example: '2025-04-19T16:45:18.767Z', + description: 'The time the application was created. If not provided, defaults to the current time.', + }) + @IsOptional() + @IsISO8601() + createdAt?: string; - @ApiPropertyOptional({ - example: "2025-04-19T16:45:18.767Z", - nullable: true, - default: null, - description: "The time the application was reviewed.", - }) - @IsOptional() - @IsISO8601() - reviewedAt?: string | null; + @ApiPropertyOptional({ + example: '2025-04-19T16:45:18.767Z', + nullable: true, + default: null, + description: 'The time the application was reviewed.', + }) + @IsOptional() + @IsISO8601() + reviewedAt?: string | null; - @ApiPropertyOptional({ - example: "User failed interview stage", - nullable: true, - default: null, - description: "The reason for the application's decision, if any.", - }) - @IsOptional() - @IsString() - reason?: string | null; + @ApiPropertyOptional({ + example: 'User failed interview stage', + nullable: true, + default: null, + description: "The reason for the application's decision, if any.", + }) + @IsOptional() + @IsString() + reason?: string | null; - @ApiPropertyOptional({ - example: "00000000-0000-0000-0000-000000000000", - nullable: true, - default: null, - description: "The ID of the claim associated with the application, if any.", - }) - @IsOptional() - @IsUUID() - claimId?: string | null; + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + nullable: true, + default: null, + description: 'The ID of the claim associated with the application, if any.', + }) + @IsOptional() + @IsUUID() + claimId?: string | null; - @ApiPropertyOptional({ - example: false, - default: false, - description: "Indicates whether the application is a trial application.", - }) - @IsOptional() - @IsBoolean() - trial?: boolean; + @ApiPropertyOptional({ + example: false, + default: false, + description: 'Indicates whether the application is a trial application.', + }) + @IsOptional() + @IsBoolean() + trial?: boolean; } diff --git a/apps/api-v2/src/sections/applications/dto/review.application.dto.ts b/apps/api-v2/src/sections/applications/dto/review.application.dto.ts index 27858ebd..e38d5680 100644 --- a/apps/api-v2/src/sections/applications/dto/review.application.dto.ts +++ b/apps/api-v2/src/sections/applications/dto/review.application.dto.ts @@ -1,66 +1,58 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { ApplicationStatus } from "@repo/db"; -import { - IsBoolean, - IsEnum, - IsISO8601, - IsOptional, - IsString, - IsUUID, -} from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApplicationStatus } from '@repo/db'; +import { IsBoolean, IsEnum, IsISO8601, IsOptional, IsString, IsUUID } from 'class-validator'; export class ReviewApplicationDto { + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + nullable: true, + description: 'The ID of the reviewer who handled the application, if any.', + }) + @IsOptional() + @IsUUID() + reviewerId?: string | null; - @ApiPropertyOptional({ - example: "00000000-0000-0000-0000-000000000000", - nullable: true, - description: "The ID of the reviewer who handled the application, if any.", - }) - @IsOptional() - @IsUUID() - reviewerId?: string | null; + @ApiPropertyOptional({ + example: ApplicationStatus.ACCEPTED, + enum: ApplicationStatus, + description: 'The new status of the application.', + }) + @IsOptional() + @IsEnum(ApplicationStatus) + status?: ApplicationStatus; - @ApiPropertyOptional({ - example: ApplicationStatus.ACCEPTED, - enum: ApplicationStatus, - description: 'The new status of the application.', - }) - @IsOptional() - @IsEnum(ApplicationStatus) - status?: ApplicationStatus; + @ApiPropertyOptional({ + example: '2025-04-19T16:45:18.767Z', + nullable: true, + description: 'The time the application was reviewed.', + }) + @IsOptional() + @IsISO8601() + reviewedAt?: string | null; - @ApiPropertyOptional({ - example: "2025-04-19T16:45:18.767Z", - nullable: true, - description: 'The time the application was reviewed.', - }) - @IsOptional() - @IsISO8601() - reviewedAt?: string | null; + @ApiPropertyOptional({ + example: 'User failed interview stage', + nullable: true, + description: 'The reason for the application decision, if any.', + }) + @IsOptional() + @IsString() + reason?: string | null; - @ApiPropertyOptional({ - example: "User failed interview stage", - nullable: true, - description: 'The reason for the application decision, if any.', - }) - @IsOptional() - @IsString() - reason?: string | null; + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + nullable: true, + description: 'The ID of the claim associated with the application, if any.', + }) + @IsOptional() + @IsUUID() + claimId?: string | null; - @ApiPropertyOptional({ - example: "00000000-0000-0000-0000-000000000000", - nullable: true, - description: 'The ID of the claim associated with the application, if any.', - }) - @IsOptional() - @IsUUID() - claimId?: string | null; - - @ApiPropertyOptional({ - example: false, - description: 'Indicates whether the application is a trial application.', - }) - @IsOptional() - @IsBoolean() - trial?: boolean; + @ApiPropertyOptional({ + example: false, + description: 'Indicates whether the application is a trial application.', + }) + @IsOptional() + @IsBoolean() + trial?: boolean; } diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.module.ts b/apps/api-v2/src/sections/applications/questions/application-questions.module.ts index e40e7130..bd8d3d96 100644 --- a/apps/api-v2/src/sections/applications/questions/application-questions.module.ts +++ b/apps/api-v2/src/sections/applications/questions/application-questions.module.ts @@ -1,10 +1,10 @@ -import { Module } from "@nestjs/common"; -import { PrismaService } from "src/common/db/prisma.service"; -import { ApplicationQuestionsController } from "./application-questions.controller"; -import { ApplicationQuestionsService } from "./application-questions.service"; +import { Module } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ApplicationQuestionsController } from './application-questions.controller'; +import { ApplicationQuestionsService } from './application-questions.service'; @Module({ - controllers: [ApplicationQuestionsController], - providers: [ApplicationQuestionsService, PrismaService], + controllers: [ApplicationQuestionsController], + providers: [ApplicationQuestionsService, PrismaService], }) export class ApplicationQuestionsModule {} diff --git a/apps/api-v2/src/sections/applications/questions/dto/application-question.dto.ts b/apps/api-v2/src/sections/applications/questions/dto/application-question.dto.ts index 0d9b4810..4747005d 100644 --- a/apps/api-v2/src/sections/applications/questions/dto/application-question.dto.ts +++ b/apps/api-v2/src/sections/applications/questions/dto/application-question.dto.ts @@ -1,80 +1,80 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { ApplicationQuestionType } from "@repo/db"; -import { IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID } from "class-validator"; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApplicationQuestionType } from '@repo/db'; +import { IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString, IsUUID } from 'class-validator'; export class ApplicationQuestionDto { - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - description: "The unique ID of the application question.", - }) - @IsUUID() - id: string; + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The unique ID of the application question.', + }) + @IsUUID() + id: string; - @ApiProperty({ - example: "What is your experience?", - }) - @IsString() - title: string; + @ApiProperty({ + example: 'What is your experience?', + }) + @IsString() + title: string; - @ApiProperty({ - example: "Tell us about your past projects and roles.", - }) - @IsString() - subtitle: string; + @ApiProperty({ + example: 'Tell us about your past projects and roles.', + }) + @IsString() + subtitle: string; - @ApiPropertyOptional({ - example: "", - default: "", - }) - @IsOptional() - @IsString() - placeholder?: string; + @ApiPropertyOptional({ + example: '', + default: '', + }) + @IsOptional() + @IsString() + placeholder?: string; - @ApiProperty({ - example: true, - default: true, - }) - @IsBoolean() - required: boolean; + @ApiProperty({ + example: true, + default: true, + }) + @IsBoolean() + required: boolean; - @ApiProperty({ - enum: ApplicationQuestionType, - example: ApplicationQuestionType.TEXT, - }) - @IsEnum(ApplicationQuestionType) - type: ApplicationQuestionType; + @ApiProperty({ + enum: ApplicationQuestionType, + example: ApplicationQuestionType.TEXT, + }) + @IsEnum(ApplicationQuestionType) + type: ApplicationQuestionType; - @ApiProperty({ - example: "briefcase", - }) - @IsString() - icon: string; + @ApiProperty({ + example: 'briefcase', + }) + @IsString() + icon: string; - @ApiProperty({ - example: {}, - description: "Extra dynamic configuration based on the question type.", - }) - @IsObject() - additionalData: Record; + @ApiProperty({ + example: {}, + description: 'Extra dynamic configuration based on the question type.', + }) + @IsObject() + additionalData: Record; - @ApiProperty({ - example: "00000000-0000-0000-0000-000000000000", - description: "The unique ID of the build team this question belongs to.", - }) - @IsUUID() - buildTeamId: string; + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The unique ID of the build team this question belongs to.', + }) + @IsUUID() + buildTeamId: string; - @ApiProperty({ - example: 1, - }) - @IsInt() - sort: number; + @ApiProperty({ + example: 1, + }) + @IsInt() + sort: number; - @ApiPropertyOptional({ - example: false, - default: false, - }) - @IsOptional() - @IsBoolean() - trial?: boolean; -} \ No newline at end of file + @ApiPropertyOptional({ + example: false, + default: false, + }) + @IsOptional() + @IsBoolean() + trial?: boolean; +} diff --git a/apps/api-v2/src/sections/auth/auth.controller.ts b/apps/api-v2/src/sections/auth/auth.controller.ts index b42e1ae0..752ce6f6 100644 --- a/apps/api-v2/src/sections/auth/auth.controller.ts +++ b/apps/api-v2/src/sections/auth/auth.controller.ts @@ -1,52 +1,44 @@ -import { Body, Controller, Get, Post, Req } from "@nestjs/common"; -import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; -import { Request } from "express"; -import { - ApiDefaultResponse, - ApiErrorResponse, -} from "src/common/decorators/api-response.decorator"; -import { SkipAuth } from "src/common/decorators/skip-auth.decorator"; -import { AuthService } from "./auth.service"; -import { AccessTokenResponseDto } from "./dto/accessTokenResponse.dto"; -import { BuildTeamProfileDto } from "./dto/buildTeamProfile.dto"; -import { GenerateAccessTokenDto } from "./dto/generateAccessToken.dto"; +import { Body, Controller, Get, Post, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { Request } from 'express'; +import { ApiDefaultResponse, ApiErrorResponse } from 'src/common/decorators/api-response.decorator'; +import { SkipAuth } from 'src/common/decorators/skip-auth.decorator'; +import { AuthService } from './auth.service'; +import { AccessTokenResponseDto } from './dto/accessTokenResponse.dto'; +import { BuildTeamProfileDto } from './dto/buildTeamProfile.dto'; +import { GenerateAccessTokenDto } from './dto/generateAccessToken.dto'; -@Controller("auth") +@Controller('auth') export class AuthController { - constructor(private readonly authService: AuthService) {} + constructor(private readonly authService: AuthService) {} - /** - * Generates a new JWT access token for BuildTeams to use in their requests. - */ - @Post("token") - @SkipAuth() - @ApiOperation({ - summary: "Generate API Access Token", - description: - "Generates an API access token for a BuildTeam using its ID and token.", - }) - @ApiDefaultResponse(AccessTokenResponseDto) - @ApiErrorResponse({ status: 401, description: "Error: Unauthorized" }) - generateAccessToken(@Body() generateAccessTokenDto: GenerateAccessTokenDto) { - return this.authService.generateAccessToken( - generateAccessTokenDto.buildTeamId, - generateAccessTokenDto.token, - ); - } + /** + * Generates a new JWT access token for BuildTeams to use in their requests. + */ + @Post('token') + @SkipAuth() + @ApiOperation({ + summary: 'Generate API Access Token', + description: 'Generates an API access token for a BuildTeam using its ID and token.', + }) + @ApiDefaultResponse(AccessTokenResponseDto) + @ApiErrorResponse({ status: 401, description: 'Error: Unauthorized' }) + generateAccessToken(@Body() generateAccessTokenDto: GenerateAccessTokenDto) { + return this.authService.generateAccessToken(generateAccessTokenDto.buildTeamId, generateAccessTokenDto.token); + } - /** - * Returns the BuildTeam profile of the attached access token. - */ - @Get("/") - @ApiOperation({ - summary: "Verify API Access Token", - description: - "Verifies the API access token and returns the BuildTeam profile.", - }) - @ApiBearerAuth() - @ApiDefaultResponse(BuildTeamProfileDto) - @ApiErrorResponse({ status: 401, description: "Error: Unauthorized" }) - getProfile(@Req() req: Request) { - return req.token; - } + /** + * Returns the BuildTeam profile of the attached access token. + */ + @Get('/') + @ApiOperation({ + summary: 'Verify API Access Token', + description: 'Verifies the API access token and returns the BuildTeam profile.', + }) + @ApiBearerAuth() + @ApiDefaultResponse(BuildTeamProfileDto) + @ApiErrorResponse({ status: 401, description: 'Error: Unauthorized' }) + getProfile(@Req() req: Request) { + return req.token; + } } diff --git a/apps/api-v2/src/sections/auth/auth.module.ts b/apps/api-v2/src/sections/auth/auth.module.ts index 37c1b930..57a35caf 100644 --- a/apps/api-v2/src/sections/auth/auth.module.ts +++ b/apps/api-v2/src/sections/auth/auth.module.ts @@ -1,23 +1,23 @@ -import { Module } from "@nestjs/common"; -import { ConfigModule, ConfigService } from "@nestjs/config"; -import { JwtModule } from "@nestjs/jwt"; -import { PrismaService } from "src/common/db/prisma.service"; -import { AuthController } from "./auth.controller"; -import { AuthService } from "./auth.service"; +import { Module } from '@nestjs/common'; +import { ConfigModule, ConfigService } from '@nestjs/config'; +import { JwtModule } from '@nestjs/jwt'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { AuthController } from './auth.controller'; +import { AuthService } from './auth.service'; @Module({ - controllers: [AuthController], - providers: [AuthService, PrismaService], - imports: [ - JwtModule.registerAsync({ - global: true, - imports: [ConfigModule], - inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ - secret: configService.getOrThrow("JWT_SECRET"), - signOptions: { issuer: "api" }, - }), - }), - ], + controllers: [AuthController], + providers: [AuthService, PrismaService], + imports: [ + JwtModule.registerAsync({ + global: true, + imports: [ConfigModule], + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + secret: configService.getOrThrow('JWT_SECRET'), + signOptions: { issuer: 'api' }, + }), + }), + ], }) export class AuthModule {} diff --git a/apps/api-v2/src/sections/auth/auth.service.ts b/apps/api-v2/src/sections/auth/auth.service.ts index 2ca2579b..0f35a498 100644 --- a/apps/api-v2/src/sections/auth/auth.service.ts +++ b/apps/api-v2/src/sections/auth/auth.service.ts @@ -1,53 +1,53 @@ -import { Injectable, UnauthorizedException } from "@nestjs/common"; -import { JwtService } from "@nestjs/jwt"; -import { PrismaService } from "src/common/db/prisma.service"; -import { BuildTeamProfileDto } from "./dto/buildTeamProfile.dto"; +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { JwtService } from '@nestjs/jwt'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { BuildTeamProfileDto } from './dto/buildTeamProfile.dto'; @Injectable() export class AuthService { - constructor( - private prisma: PrismaService, - private jwtService: JwtService, - ) {} + constructor( + private prisma: PrismaService, + private jwtService: JwtService, + ) {} - /** - * Generates a JWT access token for a BuildTeam. - * @param buildTeamId ID of the BuildTeam the token is from (usually 'username') - * @param token Token of the BuildTeam (usually 'password') - * @returns an object containing a signed JWT access token - * @throws UnauthorizedException if the BuildTeam is not found or the token does not match - */ - async generateAccessToken(buildTeamId: string, token: string) { - const buildTeam = await this.prisma.buildTeam.findUnique({ - where: { id: buildTeamId }, - select: { id: true, slug: true, token: true }, - }); + /** + * Generates a JWT access token for a BuildTeam. + * @param buildTeamId ID of the BuildTeam the token is from (usually 'username') + * @param token Token of the BuildTeam (usually 'password') + * @returns an object containing a signed JWT access token + * @throws UnauthorizedException if the BuildTeam is not found or the token does not match + */ + async generateAccessToken(buildTeamId: string, token: string) { + const buildTeam = await this.prisma.buildTeam.findUnique({ + where: { id: buildTeamId }, + select: { id: true, slug: true, token: true }, + }); - if (!buildTeam || !buildTeam.token || buildTeam.token !== token) { - throw new UnauthorizedException("Invalid BuildTeam or Token"); - } + if (!buildTeam || !buildTeam.token || buildTeam.token !== token) { + throw new UnauthorizedException('Invalid BuildTeam or Token'); + } - const payload = { - sub: buildTeam.id, - id: buildTeam.id, - slug: buildTeam.slug, - iat: Math.floor(Date.now() / 1000), - }; - return { - access_token: await this.jwtService.signAsync(payload), - }; - } + const payload = { + sub: buildTeam.id, + id: buildTeam.id, + slug: buildTeam.slug, + iat: Math.floor(Date.now() / 1000), + }; + return { + access_token: await this.jwtService.signAsync(payload), + }; + } - /** - * Validates a JWT token and returns the BuildTeam if valid. - * @param token JWT token to validate - * @returns UserProfileDto if the token is valid, null otherwise - */ - async validateJwt(token: string): Promise { - try { - return await this.jwtService.verifyAsync(token); - } catch { - return null; - } - } + /** + * Validates a JWT token and returns the BuildTeam if valid. + * @param token JWT token to validate + * @returns UserProfileDto if the token is valid, null otherwise + */ + async validateJwt(token: string): Promise { + try { + return await this.jwtService.verifyAsync(token); + } catch { + return null; + } + } } diff --git a/apps/api-v2/src/sections/auth/dto/accessTokenResponse.dto.ts b/apps/api-v2/src/sections/auth/dto/accessTokenResponse.dto.ts index 1ff961b4..8508a3fb 100644 --- a/apps/api-v2/src/sections/auth/dto/accessTokenResponse.dto.ts +++ b/apps/api-v2/src/sections/auth/dto/accessTokenResponse.dto.ts @@ -1,6 +1,6 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty } from '@nestjs/swagger'; export class AccessTokenResponseDto { - @ApiProperty({ example: "ey......" }) - access_token: string; + @ApiProperty({ example: 'ey......' }) + access_token: string; } diff --git a/apps/api-v2/src/sections/auth/dto/buildTeamProfile.dto.ts b/apps/api-v2/src/sections/auth/dto/buildTeamProfile.dto.ts index 2a12ab7d..2fec9788 100644 --- a/apps/api-v2/src/sections/auth/dto/buildTeamProfile.dto.ts +++ b/apps/api-v2/src/sections/auth/dto/buildTeamProfile.dto.ts @@ -1,17 +1,17 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty } from '@nestjs/swagger'; export class BuildTeamProfileDto { - @ApiProperty({ example: "00000000-0000-0000-0000-000000000000" }) - sub: string; + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000' }) + sub: string; - @ApiProperty({ example: "00000000-0000-0000-0000-000000000000" }) - id: string; + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000' }) + id: string; - @ApiProperty({ example: "example-slug" }) - slug: string; + @ApiProperty({ example: 'example-slug' }) + slug: string; - @ApiProperty({ example: 1700000000 }) - iat: number; + @ApiProperty({ example: 1700000000 }) + iat: number; - iss: "api"; + iss: 'api'; } diff --git a/apps/api-v2/src/sections/auth/dto/generateAccessToken.dto.ts b/apps/api-v2/src/sections/auth/dto/generateAccessToken.dto.ts index 9fd7705f..d3e97928 100644 --- a/apps/api-v2/src/sections/auth/dto/generateAccessToken.dto.ts +++ b/apps/api-v2/src/sections/auth/dto/generateAccessToken.dto.ts @@ -1,12 +1,12 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { IsString, IsUUID } from "class-validator"; +import { ApiProperty } from '@nestjs/swagger'; +import { IsString, IsUUID } from 'class-validator'; export class GenerateAccessTokenDto { - @ApiProperty({ example: "buildTeamId123" }) - @IsUUID() - buildTeamId: string; + @ApiProperty({ example: 'buildTeamId123' }) + @IsUUID() + buildTeamId: string; - @ApiProperty({ example: "secureToken456" }) - @IsString() - token: string; + @ApiProperty({ example: 'secureToken456' }) + @IsString() + token: string; } diff --git a/apps/api-v2/src/sections/claims/claims.controller.ts b/apps/api-v2/src/sections/claims/claims.controller.ts index 6ef0212e..99b58c9a 100644 --- a/apps/api-v2/src/sections/claims/claims.controller.ts +++ b/apps/api-v2/src/sections/claims/claims.controller.ts @@ -1,61 +1,53 @@ -import { Controller, Get, Req } from "@nestjs/common"; -import { ClaimsService } from "./claims.service"; -import { Filtered } from "src/common/decorators/filtered.decorator"; -import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; -import { ClaimDto } from "./dto/claim.dto"; -import { ApiPaginatedResponseDto } from "src/common/decorators/api-response.decorator"; -import { Filter, FilterParams } from "src/common/decorators/filter.decorator"; -import { Request } from "express"; -import { OptionalAuth } from "src/common/decorators/optional-auth.decorator"; -import { - Pagination, - PaginationParams, -} from "src/common/decorators/pagination.decorator"; -import { Paginated } from "src/common/decorators/paginated.decorator"; +import { Controller, Get, Req } from '@nestjs/common'; +import { ClaimsService } from './claims.service'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { ApiBearerAuth, ApiOperation } from '@nestjs/swagger'; +import { ClaimDto } from './dto/claim.dto'; +import { ApiPaginatedResponseDto } from 'src/common/decorators/api-response.decorator'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Request } from 'express'; +import { OptionalAuth } from 'src/common/decorators/optional-auth.decorator'; +import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { Paginated } from 'src/common/decorators/paginated.decorator'; -@Controller("claims") +@Controller('claims') export class ClaimsController { - constructor(private readonly claimsService: ClaimsService) {} + constructor(private readonly claimsService: ClaimsService) {} - @Get() - @OptionalAuth() - @ApiBearerAuth() - @Paginated() - @ApiOperation({ - summary: "Get All Claims", - description: - "Returns all claims for the given team. If no team is specified, returns claims for the authenticated team.", - }) - @Filtered({ - fields: [ - { name: "finished", required: false, type: Boolean }, - { name: "active", required: false, type: Boolean }, - { name: "team", required: false, type: String }, - { name: "slug", required: false, type: Boolean }, - ], - }) - @ApiPaginatedResponseDto(ClaimDto, { description: "Success" }) - findAll( - @Pagination() pagination: PaginationParams, - @Filter() filter: FilterParams, - @Req() req: Request, - ) { - const { team, slug, ...otherFilters }: { team?: string; slug?: boolean } = - filter.filter; + @Get() + @OptionalAuth() + @ApiBearerAuth() + @Paginated() + @ApiOperation({ + summary: 'Get All Claims', + description: + 'Returns all claims for the given team. If no team is specified, returns claims for the authenticated team.', + }) + @Filtered({ + fields: [ + { name: 'finished', required: false, type: Boolean }, + { name: 'active', required: false, type: Boolean }, + { name: 'team', required: false, type: String }, + { name: 'slug', required: false, type: Boolean }, + ], + }) + @ApiPaginatedResponseDto(ClaimDto, { description: 'Success' }) + findAll(@Pagination() pagination: PaginationParams, @Filter() filter: FilterParams, @Req() req: Request) { + const { team, slug, ...otherFilters }: { team?: string; slug?: boolean } = filter.filter; - const teamFilter: { - buildTeamId?: string; - buildTeam?: { slug: string }; - } = (() => { - if (!team && req.token) return { buildTeamId: req.token.id }; - if (!team) return {}; - if (slug) return { buildTeam: { slug: team } }; - return { buildTeamId: team }; - })(); + const teamFilter: { + buildTeamId?: string; + buildTeam?: { slug: string }; + } = (() => { + if (!team && req.token) return { buildTeamId: req.token.id }; + if (!team) return {}; + if (slug) return { buildTeam: { slug: team } }; + return { buildTeamId: team }; + })(); - return this.claimsService.findAll(pagination, { - ...otherFilters, - ...teamFilter, - }); - } + return this.claimsService.findAll(pagination, { + ...otherFilters, + ...teamFilter, + }); + } } diff --git a/apps/api-v2/src/sections/claims/claims.module.ts b/apps/api-v2/src/sections/claims/claims.module.ts index 3e73d73f..565bb546 100644 --- a/apps/api-v2/src/sections/claims/claims.module.ts +++ b/apps/api-v2/src/sections/claims/claims.module.ts @@ -1,10 +1,10 @@ -import { Module } from "@nestjs/common"; -import { PrismaService } from "src/common/db/prisma.service"; -import { ClaimsController } from "./claims.controller"; -import { ClaimsService } from "./claims.service"; +import { Module } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ClaimsController } from './claims.controller'; +import { ClaimsService } from './claims.service'; @Module({ - controllers: [ClaimsController], - providers: [ClaimsService, PrismaService], + controllers: [ClaimsController], + providers: [ClaimsService, PrismaService], }) export class ClaimsModule {} diff --git a/apps/api-v2/src/sections/claims/claims.service.ts b/apps/api-v2/src/sections/claims/claims.service.ts index 9376f6ad..93042f8b 100644 --- a/apps/api-v2/src/sections/claims/claims.service.ts +++ b/apps/api-v2/src/sections/claims/claims.service.ts @@ -1,38 +1,38 @@ -import { Injectable } from "@nestjs/common"; -import { FilterParams } from "src/common/decorators/filter.decorator"; -import { PrismaService } from "src/common/db/prisma.service"; -import { PaginationParams } from "src/common/decorators/pagination.decorator"; +import { Injectable } from '@nestjs/common'; +import { FilterParams } from 'src/common/decorators/filter.decorator'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { PaginationParams } from 'src/common/decorators/pagination.decorator'; @Injectable() export class ClaimsService { - constructor(private readonly prisma: PrismaService) {} + constructor(private readonly prisma: PrismaService) {} - async findAll(pagination: PaginationParams, filter: FilterParams["filter"]) { - const limit = Math.max(Number(pagination.limit) || 20, 1); - const page = Math.max(Number(pagination.page) || 1, 1); - const skip = (page - 1) * limit; + async findAll(pagination: PaginationParams, filter: FilterParams['filter']) { + const limit = Math.max(Number(pagination.limit) || 20, 1); + const page = Math.max(Number(pagination.page) || 1, 1); + const skip = (page - 1) * limit; - const [claims, total] = await Promise.all([ - this.prisma.claim.findMany({ - where: filter, - skip, - take: limit, - include: { - _count: { select: { builders: true, images: true } }, - images: { select: { id: true, name: true, hash: true } }, - }, - }), - this.prisma.claim.count({ where: filter }), - ]); + const [claims, total] = await Promise.all([ + this.prisma.claim.findMany({ + where: filter, + skip, + take: limit, + include: { + _count: { select: { builders: true, images: true } }, + images: { select: { id: true, name: true, hash: true } }, + }, + }), + this.prisma.claim.count({ where: filter }), + ]); - return { - data: claims, - meta: { - page, - perPage: limit, - totalItems: total, - totalPages: Math.ceil(total / limit), - }, - }; - } + return { + data: claims, + meta: { + page, + perPage: limit, + totalItems: total, + totalPages: Math.ceil(total / limit), + }, + }; + } } diff --git a/apps/api-v2/src/sections/claims/dto/claim.dto.ts b/apps/api-v2/src/sections/claims/dto/claim.dto.ts index 92774baf..3fdcaff6 100644 --- a/apps/api-v2/src/sections/claims/dto/claim.dto.ts +++ b/apps/api-v2/src/sections/claims/dto/claim.dto.ts @@ -1,28 +1,28 @@ export class ClaimDto { - id: string; - ownerId: string | null; - area: string[]; - center: string | null; - size: number; - active: boolean; - finished: boolean; - buildTeamId: string; - name: string; - createdAt: string; - externalId: string | null; - description: string | null; - buildings: number; - city: string | null; - osmName: string | null; + id: string; + ownerId: string | null; + area: string[]; + center: string | null; + size: number; + active: boolean; + finished: boolean; + buildTeamId: string; + name: string; + createdAt: string; + externalId: string | null; + description: string | null; + buildings: number; + city: string | null; + osmName: string | null; - _count: { - builders: number; - images: number; - }; + _count: { + builders: number; + images: number; + }; - images: { - id: string; - name: string; - hash: string; - }[]; + images: { + id: string; + name: string; + hash: string; + }[]; } diff --git a/apps/api-v2/src/sections/status/dto/globalStatus.dto.ts b/apps/api-v2/src/sections/status/dto/globalStatus.dto.ts index a269817e..54505866 100644 --- a/apps/api-v2/src/sections/status/dto/globalStatus.dto.ts +++ b/apps/api-v2/src/sections/status/dto/globalStatus.dto.ts @@ -1,9 +1,9 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty } from '@nestjs/swagger'; export class GlobalStatusDto { - @ApiProperty({ example: "operational" }) - status: string; + @ApiProperty({ example: 'operational' }) + status: string; - @ApiProperty({ example: "All systems are operational." }) - message: string; + @ApiProperty({ example: 'All systems are operational.' }) + message: string; } diff --git a/apps/api-v2/src/sections/status/dto/incident.dto.ts b/apps/api-v2/src/sections/status/dto/incident.dto.ts index d278022e..b62f9a0e 100644 --- a/apps/api-v2/src/sections/status/dto/incident.dto.ts +++ b/apps/api-v2/src/sections/status/dto/incident.dto.ts @@ -1,28 +1,28 @@ -import { ApiProperty } from "@nestjs/swagger"; -import { StatusComponentStatusDto } from "./statusComponent.dto"; +import { ApiProperty } from '@nestjs/swagger'; +import { StatusComponentStatusDto } from './statusComponent.dto'; export class IncidentTimeDto { - @ApiProperty({ example: "1 day ago" }) - human: string; + @ApiProperty({ example: '1 day ago' }) + human: string; - @ApiProperty({ example: "2024-01-01T12:00:00Z" }) - string: string; + @ApiProperty({ example: '2024-01-01T12:00:00Z' }) + string: string; } export class IncidentDto { - @ApiProperty({ example: 1 }) - id: number; + @ApiProperty({ example: 1 }) + id: number; - @ApiProperty({ example: "Unavailibility of the Network API" }) - name: string; + @ApiProperty({ example: 'Unavailibility of the Network API' }) + name: string; - @ApiProperty({ example: "...", nullable: true }) - message: string | null; + @ApiProperty({ example: '...', nullable: true }) + message: string | null; - @ApiProperty({ type: () => StatusComponentStatusDto }) - status: StatusComponentStatusDto; + @ApiProperty({ type: () => StatusComponentStatusDto }) + status: StatusComponentStatusDto; - @ApiProperty({ type: () => IncidentTimeDto }) - created_at: IncidentTimeDto; - occurred_at: IncidentTimeDto; + @ApiProperty({ type: () => IncidentTimeDto }) + created_at: IncidentTimeDto; + occurred_at: IncidentTimeDto; } diff --git a/apps/api-v2/src/sections/status/dto/statusComponent.dto.ts b/apps/api-v2/src/sections/status/dto/statusComponent.dto.ts index 3182eae7..54ec7ee9 100644 --- a/apps/api-v2/src/sections/status/dto/statusComponent.dto.ts +++ b/apps/api-v2/src/sections/status/dto/statusComponent.dto.ts @@ -1,29 +1,29 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty } from '@nestjs/swagger'; export class StatusComponentStatusDto { - @ApiProperty({ example: "Operational" }) - human: string; + @ApiProperty({ example: 'Operational' }) + human: string; - @ApiProperty({ example: 1 }) - value: number; + @ApiProperty({ example: 1 }) + value: number; } export class StatusComponentDto { - @ApiProperty({ example: 1 }) - id: number; + @ApiProperty({ example: 1 }) + id: number; - @ApiProperty({ example: "BuildTheEarth API" }) - name: string; + @ApiProperty({ example: 'BuildTheEarth API' }) + name: string; - @ApiProperty({ example: "https://api.buildtheearth.net", nullable: true }) - link: string | null; + @ApiProperty({ example: 'https://api.buildtheearth.net', nullable: true }) + link: string | null; - @ApiProperty({ example: "The main API for BuildTheEarth.", nullable: true }) - description: string; + @ApiProperty({ example: 'The main API for BuildTheEarth.', nullable: true }) + description: string; - @ApiProperty({ example: "api", nullable: true }) - type?: string; + @ApiProperty({ example: 'api', nullable: true }) + type?: string; - @ApiProperty({ type: () => StatusComponentStatusDto }) - status: StatusComponentStatusDto; + @ApiProperty({ type: () => StatusComponentStatusDto }) + status: StatusComponentStatusDto; } diff --git a/apps/api-v2/src/sections/status/status.controller.ts b/apps/api-v2/src/sections/status/status.controller.ts index fae424b1..9501f269 100644 --- a/apps/api-v2/src/sections/status/status.controller.ts +++ b/apps/api-v2/src/sections/status/status.controller.ts @@ -1,86 +1,81 @@ -import { Controller, Get, ServiceUnavailableException } from "@nestjs/common"; -import { ApiOperation } from "@nestjs/swagger"; -import { - ApiDefaultResponse, - ApiErrorResponse, -} from "src/common/decorators/api-response.decorator"; -import { Filter, FilterParams } from "src/common/decorators/filter.decorator"; -import { Filtered } from "src/common/decorators/filtered.decorator"; -import { SkipAuth } from "src/common/decorators/skip-auth.decorator"; -import { GlobalStatusDto } from "./dto/globalStatus.dto"; -import { IncidentDto } from "./dto/incident.dto"; -import { StatusComponentDto } from "./dto/statusComponent.dto"; -import { StatusService } from "./status.service"; +import { Controller, Get, ServiceUnavailableException } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; +import { ApiDefaultResponse, ApiErrorResponse } from 'src/common/decorators/api-response.decorator'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { SkipAuth } from 'src/common/decorators/skip-auth.decorator'; +import { GlobalStatusDto } from './dto/globalStatus.dto'; +import { IncidentDto } from './dto/incident.dto'; +import { StatusComponentDto } from './dto/statusComponent.dto'; +import { StatusService } from './status.service'; -@Controller("/status") +@Controller('/status') export class StatusController { - constructor(private readonly statusService: StatusService) {} + constructor(private readonly statusService: StatusService) {} - /** - * Returns the health status of the Cachet API. - */ - @Get("/test") - @SkipAuth() - @ApiErrorResponse({ status: 503, description: "Error: Service Unavailable" }) - @ApiOperation({ - summary: "Test Status API Connection", - description: "Returns OK if the Status API is reachable, FAIL otherwise.", - }) - async testConnection(): Promise<"OK"> { - if ((await this.statusService.testConnection()) == "Pong!") { - return "OK"; - } else { - throw new ServiceUnavailableException("Status API is unreachable"); - } - } + /** + * Returns the health status of the Cachet API. + */ + @Get('/test') + @SkipAuth() + @ApiErrorResponse({ status: 503, description: 'Error: Service Unavailable' }) + @ApiOperation({ + summary: 'Test Status API Connection', + description: 'Returns OK if the Status API is reachable, FAIL otherwise.', + }) + async testConnection(): Promise<'OK'> { + if ((await this.statusService.testConnection()) == 'Pong!') { + return 'OK'; + } else { + throw new ServiceUnavailableException('Status API is unreachable'); + } + } - /** - * Returns the global status from the Cachet API. - */ - @Get("/") - @SkipAuth() - @ApiDefaultResponse(GlobalStatusDto) - @ApiErrorResponse({ status: 503, description: "Error: Service Unavailable" }) - @ApiOperation({ - summary: "Get Global Status", - description: "Returns the global status from the Status API.", - }) - async getGlobalStatus(): Promise<{ status: string; message: string }> { - return this.statusService.getGlobalStatus(); - } + /** + * Returns the global status from the Cachet API. + */ + @Get('/') + @SkipAuth() + @ApiDefaultResponse(GlobalStatusDto) + @ApiErrorResponse({ status: 503, description: 'Error: Service Unavailable' }) + @ApiOperation({ + summary: 'Get Global Status', + description: 'Returns the global status from the Status API.', + }) + async getGlobalStatus(): Promise<{ status: string; message: string }> { + return this.statusService.getGlobalStatus(); + } - /** - * Returns the components from the Cachet API. - */ - @Get("/components") - @SkipAuth() - @ApiDefaultResponse(StatusComponentDto, { isArray: true }) - @ApiErrorResponse({ status: 503, description: "Error: Service Unavailable" }) - @ApiOperation({ - summary: "Get Components", - description: "Returns the components from the Status API.", - }) - @Filtered({ - fields: [{ name: "status", required: false, enum: [1, 2, 3, 4, 5, 6] }], - }) - async getComponents( - @Filter() filter: FilterParams, - ): Promise { - return this.statusService.getComponents(filter.filter); - } + /** + * Returns the components from the Cachet API. + */ + @Get('/components') + @SkipAuth() + @ApiDefaultResponse(StatusComponentDto, { isArray: true }) + @ApiErrorResponse({ status: 503, description: 'Error: Service Unavailable' }) + @ApiOperation({ + summary: 'Get Components', + description: 'Returns the components from the Status API.', + }) + @Filtered({ + fields: [{ name: 'status', required: false, enum: [1, 2, 3, 4, 5, 6] }], + }) + async getComponents(@Filter() filter: FilterParams): Promise { + return this.statusService.getComponents(filter.filter); + } - /** - * Returns the latest 30 incidents from the Cachet API. - */ - @Get("/incidents") - @SkipAuth() - @ApiDefaultResponse(IncidentDto, { isArray: true }) - @ApiErrorResponse({ status: 503, description: "Error: Service Unavailable" }) - @ApiOperation({ - summary: "Get Incidents", - description: "Returns the latest 30 incidents from the Status API.", - }) - async getIncidents(): Promise { - return this.statusService.getIncidents(); - } + /** + * Returns the latest 30 incidents from the Cachet API. + */ + @Get('/incidents') + @SkipAuth() + @ApiDefaultResponse(IncidentDto, { isArray: true }) + @ApiErrorResponse({ status: 503, description: 'Error: Service Unavailable' }) + @ApiOperation({ + summary: 'Get Incidents', + description: 'Returns the latest 30 incidents from the Status API.', + }) + async getIncidents(): Promise { + return this.statusService.getIncidents(); + } } diff --git a/apps/api-v2/src/sections/status/status.module.ts b/apps/api-v2/src/sections/status/status.module.ts index ce241dca..dab3df8e 100644 --- a/apps/api-v2/src/sections/status/status.module.ts +++ b/apps/api-v2/src/sections/status/status.module.ts @@ -1,12 +1,12 @@ -import { HttpModule } from "@nestjs/axios"; -import { Module } from "@nestjs/common"; -import { CachetAPIService } from "src/common/db/external/cachet.service"; -import { StatusController } from "./status.controller"; -import { StatusService } from "./status.service"; +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { CachetAPIService } from 'src/common/db/external/cachet.service'; +import { StatusController } from './status.controller'; +import { StatusService } from './status.service'; @Module({ - controllers: [StatusController], - providers: [StatusService, CachetAPIService], - imports: [HttpModule], + controllers: [StatusController], + providers: [StatusService, CachetAPIService], + imports: [HttpModule], }) export class StatusModule {} diff --git a/apps/api-v2/src/sections/status/status.service.ts b/apps/api-v2/src/sections/status/status.service.ts index 760d1fa6..5bd3930d 100644 --- a/apps/api-v2/src/sections/status/status.service.ts +++ b/apps/api-v2/src/sections/status/status.service.ts @@ -1,59 +1,53 @@ -import { Injectable, ServiceUnavailableException } from "@nestjs/common"; -import { CachetAPIService } from "src/common/db/external/cachet.service"; +import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { CachetAPIService } from 'src/common/db/external/cachet.service'; @Injectable() export class StatusService { - constructor(private readonly cachetAPIService: CachetAPIService) {} + constructor(private readonly cachetAPIService: CachetAPIService) {} - async testConnection(): Promise { - try { - return await this.cachetAPIService.testConnection(); - } catch { - throw new ServiceUnavailableException("Status API is unreachable"); - } - } + async testConnection(): Promise { + try { + return await this.cachetAPIService.testConnection(); + } catch { + throw new ServiceUnavailableException('Status API is unreachable'); + } + } - async getGlobalStatus(): Promise<{ status: string; message: string }> { - try { - return await this.cachetAPIService.getGlobalStatus(); - } catch { - throw new ServiceUnavailableException("Status API is unreachable"); - } - } + async getGlobalStatus(): Promise<{ status: string; message: string }> { + try { + return await this.cachetAPIService.getGlobalStatus(); + } catch { + throw new ServiceUnavailableException('Status API is unreachable'); + } + } - async getComponents({ - status, - }: { - status?: 1 | 2 | 3 | 4 | 5 | 6; - }): Promise { - try { - return (await this.cachetAPIService.getComponents({ status })).map( - (component) => ({ - id: component.attributes.id, - name: component.attributes.name, - link: component.attributes.link, - description: component.attributes.description, - status: component.attributes.status, - type: component.attributes.meta.type, - }), - ); - } catch { - throw new ServiceUnavailableException("Status API is unreachable"); - } - } + async getComponents({ status }: { status?: 1 | 2 | 3 | 4 | 5 | 6 }): Promise { + try { + return (await this.cachetAPIService.getComponents({ status })).map((component) => ({ + id: component.attributes.id, + name: component.attributes.name, + link: component.attributes.link, + description: component.attributes.description, + status: component.attributes.status, + type: component.attributes.meta.type, + })); + } catch { + throw new ServiceUnavailableException('Status API is unreachable'); + } + } - async getIncidents(): Promise { - try { - return (await this.cachetAPIService.getIncidents()).map((incident) => ({ - id: incident.attributes.id, - name: incident.attributes.name, - message: incident.attributes.message, - status: incident.attributes.status, - created_at: incident.attributes.created, - occurred_at: incident.attributes.occurred, - })); - } catch { - throw new ServiceUnavailableException("Status API is unreachable"); - } - } + async getIncidents(): Promise { + try { + return (await this.cachetAPIService.getIncidents()).map((incident) => ({ + id: incident.attributes.id, + name: incident.attributes.name, + message: incident.attributes.message, + status: incident.attributes.status, + created_at: incident.attributes.created, + occurred_at: incident.attributes.occurred, + })); + } catch { + throw new ServiceUnavailableException('Status API is unreachable'); + } + } } diff --git a/apps/api-v2/src/sections/utility/dto/health.dto.ts b/apps/api-v2/src/sections/utility/dto/health.dto.ts index 41766527..100a9fbc 100644 --- a/apps/api-v2/src/sections/utility/dto/health.dto.ts +++ b/apps/api-v2/src/sections/utility/dto/health.dto.ts @@ -1,9 +1,9 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty } from '@nestjs/swagger'; export class HealthDto { - @ApiProperty({ example: "ok" }) - status: "ok"; + @ApiProperty({ example: 'ok' }) + status: 'ok'; - @ApiProperty({ example: "2025-07-12T12:41:27.871Z" }) - timestamp: string; + @ApiProperty({ example: '2025-07-12T12:41:27.871Z' }) + timestamp: string; } diff --git a/apps/api-v2/src/sections/utility/dto/version.dto.ts b/apps/api-v2/src/sections/utility/dto/version.dto.ts index 27ca3fb3..8f088127 100644 --- a/apps/api-v2/src/sections/utility/dto/version.dto.ts +++ b/apps/api-v2/src/sections/utility/dto/version.dto.ts @@ -1,12 +1,12 @@ -import { ApiProperty } from "@nestjs/swagger"; +import { ApiProperty } from '@nestjs/swagger'; export class VersionDto { - @ApiProperty({ example: "2.0.0" }) - version: string; + @ApiProperty({ example: '2.0.0' }) + version: string; - @ApiProperty({ example: "v2" }) - apiVersion: string; + @ApiProperty({ example: 'v2' }) + apiVersion: string; - @ApiProperty({ example: "api-v2" }) - name: string; + @ApiProperty({ example: 'api-v2' }) + name: string; } diff --git a/apps/api-v2/src/sections/utility/utility.controller.ts b/apps/api-v2/src/sections/utility/utility.controller.ts index e04678bb..54620cad 100644 --- a/apps/api-v2/src/sections/utility/utility.controller.ts +++ b/apps/api-v2/src/sections/utility/utility.controller.ts @@ -1,80 +1,66 @@ -import { - Controller, - Get, - GoneException, - Header, - HttpCode, - Version, - VERSION_NEUTRAL, -} from "@nestjs/common"; -import { ApiOperation } from "@nestjs/swagger"; -import { - ApiDefaultResponse, - ApiErrorResponse, -} from "src/common/decorators/api-response.decorator"; -import { SkipAuth } from "src/common/decorators/skip-auth.decorator"; -import { HealthDto } from "./dto/health.dto"; -import { VersionDto } from "./dto/version.dto"; +import { Controller, Get, GoneException, Header, HttpCode, Version, VERSION_NEUTRAL } from '@nestjs/common'; +import { ApiOperation } from '@nestjs/swagger'; +import { ApiDefaultResponse, ApiErrorResponse } from 'src/common/decorators/api-response.decorator'; +import { SkipAuth } from 'src/common/decorators/skip-auth.decorator'; +import { HealthDto } from './dto/health.dto'; +import { VersionDto } from './dto/version.dto'; @Controller() export class UtilityController { - /** - * Handles deprecated API routes. - */ - @Version(VERSION_NEUTRAL) - @Get("/api/v1/*path") - @SkipAuth() - @Header("Deprecation", "@1767265200") - @Header("Sunset", "Mon, 01 Jun 2026 10:00:00 UTC") - @HttpCode(410) - @ApiOperation({ - summary: "Deprecated API endpoints", - description: - "These endpoints are deprecated and will be removed in the future. Please use the new API version 2.", - }) - @ApiErrorResponse({ status: 410, description: "Error: Gone" }) - getOldRoutes(): any { - throw new GoneException( - "Deprecated API endpoint. Please use the new API version 2", - ); - } + /** + * Handles deprecated API routes. + */ + @Version(VERSION_NEUTRAL) + @Get('/api/v1/*path') + @SkipAuth() + @Header('Deprecation', '@1767265200') + @Header('Sunset', 'Mon, 01 Jun 2026 10:00:00 UTC') + @HttpCode(410) + @ApiOperation({ + summary: 'Deprecated API endpoints', + description: 'These endpoints are deprecated and will be removed in the future. Please use the new API version 2.', + }) + @ApiErrorResponse({ status: 410, description: 'Error: Gone' }) + getOldRoutes(): any { + throw new GoneException('Deprecated API endpoint. Please use the new API version 2'); + } - /** - * Returns the health status of the API. - */ - @Get("/health") - @SkipAuth() - @ApiOperation({ - summary: "Health Check", - description: "Returns the health status of the API.", - }) - @ApiDefaultResponse(HealthDto, { description: "API is online" }) - @ApiErrorResponse({ - status: 500, - description: "Error: Internal Server Error", - }) - getHealth(): any { - return { - status: "ok", - timestamp: new Date().toISOString(), - }; - } + /** + * Returns the health status of the API. + */ + @Get('/health') + @SkipAuth() + @ApiOperation({ + summary: 'Health Check', + description: 'Returns the health status of the API.', + }) + @ApiDefaultResponse(HealthDto, { description: 'API is online' }) + @ApiErrorResponse({ + status: 500, + description: 'Error: Internal Server Error', + }) + getHealth(): any { + return { + status: 'ok', + timestamp: new Date().toISOString(), + }; + } - /** - * Returns the current version of the API. - */ - @Get("/version") - @SkipAuth() - @ApiOperation({ - summary: "API Version", - description: "Returns the current version of the API.", - }) - @ApiDefaultResponse(VersionDto) - getVersion(): any { - return { - version: process.env.npm_package_version || "unknown", - apiVersion: "v2", - name: process.env.npm_package_name || "unknown", - }; - } + /** + * Returns the current version of the API. + */ + @Get('/version') + @SkipAuth() + @ApiOperation({ + summary: 'API Version', + description: 'Returns the current version of the API.', + }) + @ApiDefaultResponse(VersionDto) + getVersion(): any { + return { + version: process.env.npm_package_version || 'unknown', + apiVersion: 'v2', + name: process.env.npm_package_name || 'unknown', + }; + } } diff --git a/apps/api-v2/src/sections/utility/utility.module.ts b/apps/api-v2/src/sections/utility/utility.module.ts index 9de605d4..69aa8e2e 100644 --- a/apps/api-v2/src/sections/utility/utility.module.ts +++ b/apps/api-v2/src/sections/utility/utility.module.ts @@ -1,9 +1,9 @@ -import { Module } from "@nestjs/common"; -import { UtilityController } from "./utility.controller"; -import { UtilityService } from "./utility.service"; +import { Module } from '@nestjs/common'; +import { UtilityController } from './utility.controller'; +import { UtilityService } from './utility.service'; @Module({ - controllers: [UtilityController], - providers: [UtilityService], + controllers: [UtilityController], + providers: [UtilityService], }) export class UtilityModule {} diff --git a/apps/api-v2/src/sections/utility/utility.service.ts b/apps/api-v2/src/sections/utility/utility.service.ts index 567a8621..cd1a4918 100644 --- a/apps/api-v2/src/sections/utility/utility.service.ts +++ b/apps/api-v2/src/sections/utility/utility.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable } from '@nestjs/common'; @Injectable() export class UtilityService {} diff --git a/apps/api-v2/src/typings/express.d.ts b/apps/api-v2/src/typings/express.d.ts index b1a93407..46497c27 100644 --- a/apps/api-v2/src/typings/express.d.ts +++ b/apps/api-v2/src/typings/express.d.ts @@ -1,9 +1,9 @@ -import { BuildTeamProfileDto } from "src/sections/auth/dto/buildTeamProfile.dto"; +import { BuildTeamProfileDto } from 'src/sections/auth/dto/buildTeamProfile.dto'; declare global { - namespace Express { - interface Request { - token: BuildTeamProfileDto; - } - } + namespace Express { + interface Request { + token: BuildTeamProfileDto; + } + } } diff --git a/apps/api-v2/src/typings/index.ts b/apps/api-v2/src/typings/index.ts index 372224c3..bb83a440 100644 --- a/apps/api-v2/src/typings/index.ts +++ b/apps/api-v2/src/typings/index.ts @@ -1,27 +1,25 @@ export interface Response { - status: number; - message: string; - data: T; - meta?: PaginatedMeta; + status: number; + message: string; + data: T; + meta?: PaginatedMeta; } export interface PaginatedMeta<> { - page: number; - perPage: number; - totalItems: number; - totalPages: number; + page: number; + perPage: number; + totalItems: number; + totalPages: number; } export interface PaginatedResponse extends Response { - meta: PaginatedMeta; + meta: PaginatedMeta; } export type ControllerResponse = Promise; export type PaginatedControllerResponse = Promise<{ - data: T[]; - meta: PaginatedMeta; + data: T[]; + meta: PaginatedMeta; }>; -export type GenericControllerResponse = - | ControllerResponse - | PaginatedControllerResponse; +export type GenericControllerResponse = ControllerResponse | PaginatedControllerResponse; export type GenericResponse = Response | PaginatedResponse; From f8589c7a323b0d5404d36c04c9a474d49a88955c Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:18:11 +0200 Subject: [PATCH 10/21] fix(api/v2): :bug: Return 404 when deleting an unknown question `delete` used `deleteMany` scoped to the authenticated team, so deleting a question that does not exist, or that belongs to another team, quietly reported success and returned `{ count: 0 }`. The route already documented a 404 for that case but could never produce one. It now throws NotFoundException when nothing was deleted and returns no body. The documented status of the success case is corrected to the 200 the route actually returns. Co-Authored-By: Claude Opus 5 --- .../questions/application-questions.controller.ts | 2 +- .../questions/application-questions.service.ts | 14 ++++++++++++-- .../application-questions.service.spec.ts | 10 ++++++++-- 3 files changed, 21 insertions(+), 5 deletions(-) diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts b/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts index 5d79c453..2f302a3a 100644 --- a/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts +++ b/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts @@ -73,7 +73,7 @@ export class ApplicationQuestionsController { summary: 'Delete Application Question', description: 'Deletes the question with the given ID if it belongs to the currently authenticated team.', }) - @ApiResponse({ status: 204, description: 'No Content' }) + @ApiResponse({ status: 200, description: 'Question deleted successfully.' }) @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) @ApiErrorResponse({ status: 404, description: 'Question not found' }) async deleteApplicationQuestion(@Param('id') id: string, @Req() req: Request): ControllerResponse { diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.service.ts b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts index 49fecb00..19a7e3e6 100644 --- a/apps/api-v2/src/sections/applications/questions/application-questions.service.ts +++ b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/common/db/prisma.service'; import { FilterParams } from 'src/common/decorators/filter.decorator'; import { PaginationParams } from 'src/common/decorators/pagination.decorator'; @@ -47,12 +47,22 @@ export class ApplicationQuestionsService { }; } + /** + * Deletes a question if it belongs to the given team. + * @param id ID of the question to delete. + * @param buildTeamId ID of the team the question has to belong to. + * @throws NotFoundException if the question does not exist or belongs to another team. + */ async delete(id: string, buildTeamId: string) { - return await this.prisma.applicationQuestion.deleteMany({ + const { count } = await this.prisma.applicationQuestion.deleteMany({ where: { id, buildTeamId, }, }); + + if (count === 0) { + throw new NotFoundException('Question not found'); + } } } diff --git a/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts b/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts index 0ab2ffa0..470d647b 100644 --- a/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts +++ b/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts @@ -1,3 +1,4 @@ +import { NotFoundException } from '@nestjs/common'; import { ApplicationQuestionsService } from 'src/sections/applications/questions/application-questions.service'; import { PrismaService } from 'src/common/db/prisma.service'; @@ -23,7 +24,7 @@ describe('ApplicationQuestionsService', () => { it('should delete the question for the given question and team ids', async () => { prismaService.applicationQuestion.deleteMany.mockResolvedValue({ count: 1 }); - const result = await applicationQuestionsService.delete('question-1', 'team-123'); + await expect(applicationQuestionsService.delete('question-1', 'team-123')).resolves.toBeUndefined(); expect(prismaService.applicationQuestion.deleteMany).toHaveBeenCalledWith({ where: { @@ -31,7 +32,12 @@ describe('ApplicationQuestionsService', () => { buildTeamId: 'team-123', }, }); - expect(result).toEqual({ count: 1 }); + }); + + it('should throw when the question does not belong to the team', async () => { + prismaService.applicationQuestion.deleteMany.mockResolvedValue({ count: 0 }); + + await expect(applicationQuestionsService.delete('question-1', 'team-123')).rejects.toThrow(NotFoundException); }); }); }); From b7967db19f86853d468c3ec9ecdacdce79cc3525 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:19:40 +0200 Subject: [PATCH 11/21] feat(api/v2): :sparkles: Implement application question write routes Adds the three write routes of #59: POST /applications/questions PUT /applications/questions (bulk upsert) PUT /applications/questions/:id Every entry of the bulk upsert is sent in full: entries carrying an ID replace the matching question, entries without one are created. Questions of the team that are not part of the payload are left untouched, and the whole batch runs in a single transaction. All writes are scoped to the authenticated team through `updateMany` on `{ id, buildTeamId }`, and the bulk route rejects IDs owned by another team with a 403 rather than overwriting them, which is what the v1 implementation did. Co-Authored-By: Claude Opus 5 --- .../application-questions.controller.ts | 96 +++++++++-- .../application-questions.service.ts | 79 ++++++++- .../dto/create.application-question.dto.ts | 77 +++++++++ .../dto/update.application-question.dto.ts | 8 + .../dto/upsert.application-question.dto.ts | 17 ++ .../application-questions.controller.spec.ts | 59 +++++++ .../application-questions.service.spec.ts | 152 +++++++++++++++++- 7 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 apps/api-v2/src/sections/applications/questions/dto/create.application-question.dto.ts create mode 100644 apps/api-v2/src/sections/applications/questions/dto/update.application-question.dto.ts create mode 100644 apps/api-v2/src/sections/applications/questions/dto/upsert.application-question.dto.ts diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts b/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts index 2f302a3a..acede041 100644 --- a/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts +++ b/apps/api-v2/src/sections/applications/questions/application-questions.controller.ts @@ -1,20 +1,23 @@ -import { Controller, Delete, Get, Param, Req } from '@nestjs/common'; -import { ApplicationQuestionsService } from './application-questions.service'; -import { ControllerResponse, PaginatedControllerResponse } from 'src/typings'; -import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Body, Controller, Delete, Get, Param, ParseArrayPipe, Post, Put, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { Request } from 'express'; -import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; -import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; -import { Sorting, SortingParams } from 'src/common/decorators/sorting.decorator'; -import { Sortable } from 'src/common/decorators/sortable.decorator'; -import { Paginated } from 'src/common/decorators/paginated.decorator'; -import { Filtered } from 'src/common/decorators/filtered.decorator'; import { ApiDefaultResponse, ApiErrorResponse, ApiPaginatedResponseDto, } from 'src/common/decorators/api-response.decorator'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { Paginated } from 'src/common/decorators/paginated.decorator'; +import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { Sortable } from 'src/common/decorators/sortable.decorator'; +import { Sorting, SortingParams } from 'src/common/decorators/sorting.decorator'; +import { ControllerResponse, PaginatedControllerResponse } from 'src/typings'; +import { ApplicationQuestionsService } from './application-questions.service'; import { ApplicationQuestionDto } from './dto/application-question.dto'; +import { CreateApplicationQuestionDto } from './dto/create.application-question.dto'; +import { UpdateApplicationQuestionDto } from './dto/update.application-question.dto'; +import { UpsertApplicationQuestionDto } from './dto/upsert.application-question.dto'; @Controller('applications/questions') export class ApplicationQuestionsController { @@ -64,6 +67,79 @@ export class ApplicationQuestionsController { ); } + /** + * Creates a new application question for the currently authenticated team. + */ + @Post('/') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create Application Question', + description: 'Creates a new application question for the currently authenticated team.', + }) + @ApiDefaultResponse(ApplicationQuestionDto, { + status: 201, + description: 'Question created successfully.', + }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + async createApplicationQuestion( + @Body() createApplicationQuestionDto: CreateApplicationQuestionDto, + @Req() req: Request, + ): ControllerResponse { + return await this.applicationQuestionsService.create(createApplicationQuestionDto, req.token.id); + } + + /** + * Creates and updates multiple application questions of the currently authenticated + * team in one request. Questions that are not part of the payload are left untouched. + */ + @Put('/') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Upsert Application Questions', + description: + 'Creates and updates multiple application questions of the currently authenticated team in one request. Entries with an ID replace the matching question, entries without one are created. Questions that are not part of the payload are left untouched.', + }) + @ApiBody({ type: [UpsertApplicationQuestionDto] }) + @ApiDefaultResponse(ApplicationQuestionDto, { isArray: true, description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 403, description: 'Question belongs to another build team' }) + async upsertApplicationQuestions( + @Body( + new ParseArrayPipe({ + items: UpsertApplicationQuestionDto, + whitelist: true, + forbidNonWhitelisted: true, + }), + ) + upsertApplicationQuestionDtos: UpsertApplicationQuestionDto[], + @Req() req: Request, + ): ControllerResponse { + return await this.applicationQuestionsService.upsertMany(upsertApplicationQuestionDtos, req.token.id); + } + + /** + * Updates the question with the given ID if it belongs to the currently authenticated team. + */ + @Put(':id') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update Application Question', + description: 'Updates the question with the given ID if it belongs to the currently authenticated team.', + }) + @ApiDefaultResponse(ApplicationQuestionDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Question not found' }) + async updateApplicationQuestion( + @Param('id') id: string, + @Body() updateApplicationQuestionDto: UpdateApplicationQuestionDto, + @Req() req: Request, + ): ControllerResponse { + return await this.applicationQuestionsService.update(id, updateApplicationQuestionDto, req.token.id); + } + /** * Deletes the question with the given ID if it belongs to the currently authenticated team. */ diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.service.ts b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts index 19a7e3e6..641a1ee7 100644 --- a/apps/api-v2/src/sections/applications/questions/application-questions.service.ts +++ b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts @@ -1,8 +1,11 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/common/db/prisma.service'; import { FilterParams } from 'src/common/decorators/filter.decorator'; import { PaginationParams } from 'src/common/decorators/pagination.decorator'; import { SortingParams } from 'src/common/decorators/sorting.decorator'; +import { CreateApplicationQuestionDto } from './dto/create.application-question.dto'; +import { UpdateApplicationQuestionDto } from './dto/update.application-question.dto'; +import { UpsertApplicationQuestionDto } from './dto/upsert.application-question.dto'; @Injectable() export class ApplicationQuestionsService { @@ -47,6 +50,80 @@ export class ApplicationQuestionsService { }; } + /** + * Creates a new question for the given team. + * @param question The question to create. + * @param buildTeamId ID of the team the question belongs to. + * @returns The created question. + */ + async create(question: CreateApplicationQuestionDto, buildTeamId: string) { + return await this.prisma.applicationQuestion.create({ + data: { ...question, buildTeamId }, + }); + } + + /** + * Updates a single question if it belongs to the given team. + * @param id ID of the question to update. + * @param question The fields to update. + * @param buildTeamId ID of the team the question has to belong to. + * @returns The updated question. + * @throws NotFoundException if the question does not exist or belongs to another team. + */ + async update(id: string, question: UpdateApplicationQuestionDto, buildTeamId: string) { + const { count } = await this.prisma.applicationQuestion.updateMany({ + where: { id, buildTeamId }, + data: question, + }); + + if (count === 0) { + throw new NotFoundException('Question not found'); + } + + return await this.prisma.applicationQuestion.findUnique({ where: { id } }); + } + + /** + * Replaces the questions sent in the payload and creates the ones that do not + * exist yet. Questions of the team that are not part of the payload are left + * untouched. + * @param questions The questions to create or update. + * @param buildTeamId ID of the team the questions belong to. + * @returns All created and updated questions. + * @throws ForbiddenException if one of the given IDs belongs to another team. + */ + async upsertMany(questions: UpsertApplicationQuestionDto[], buildTeamId: string) { + const requestedIds = questions.map((question) => question.id).filter((id): id is string => Boolean(id)); + + const existing = requestedIds.length + ? await this.prisma.applicationQuestion.findMany({ + where: { id: { in: requestedIds } }, + select: { id: true, buildTeamId: true }, + }) + : []; + + if (existing.some((question) => question.buildTeamId !== buildTeamId)) { + throw new ForbiddenException('Cannot update questions of another build team'); + } + + const existingIds = new Set(existing.map((question) => question.id)); + + return await this.prisma.$transaction( + questions.map(({ id, ...question }) => { + if (id && existingIds.has(id)) { + return this.prisma.applicationQuestion.update({ + where: { id }, + data: question, + }); + } + + return this.prisma.applicationQuestion.create({ + data: { ...question, ...(id ? { id } : {}), buildTeamId }, + }); + }), + ); + } + /** * Deletes a question if it belongs to the given team. * @param id ID of the question to delete. diff --git a/apps/api-v2/src/sections/applications/questions/dto/create.application-question.dto.ts b/apps/api-v2/src/sections/applications/questions/dto/create.application-question.dto.ts new file mode 100644 index 00000000..a1d28d8c --- /dev/null +++ b/apps/api-v2/src/sections/applications/questions/dto/create.application-question.dto.ts @@ -0,0 +1,77 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { ApplicationQuestionType } from '@repo/db'; +import { IsBoolean, IsEnum, IsInt, IsObject, IsOptional, IsString } from 'class-validator'; + +export class CreateApplicationQuestionDto { + @ApiProperty({ + example: 'What is your experience?', + description: 'The question shown to the applicant.', + }) + @IsString() + title: string; + + @ApiProperty({ + example: 'Tell us about your past projects and roles.', + description: 'The additional explanation shown below the title.', + }) + @IsString() + subtitle: string; + + @ApiPropertyOptional({ + example: '', + default: '', + description: 'The placeholder shown inside the input.', + }) + @IsOptional() + @IsString() + placeholder?: string; + + @ApiPropertyOptional({ + example: true, + default: true, + description: 'Whether the applicant has to answer this question.', + }) + @IsOptional() + @IsBoolean() + required?: boolean; + + @ApiProperty({ + enum: ApplicationQuestionType, + example: ApplicationQuestionType.TEXT, + description: 'The input type rendered for this question.', + }) + @IsEnum(ApplicationQuestionType) + type: ApplicationQuestionType; + + @ApiProperty({ + example: 'briefcase', + description: 'The icon shown next to the question.', + }) + @IsString() + icon: string; + + @ApiPropertyOptional({ + example: {}, + default: {}, + description: 'Extra dynamic configuration based on the question type.', + }) + @IsOptional() + @IsObject() + additionalData?: Record; + + @ApiProperty({ + example: 1, + description: 'The position of this question inside the application form.', + }) + @IsInt() + sort: number; + + @ApiPropertyOptional({ + example: false, + default: false, + description: 'Whether this question is only asked for trial applications.', + }) + @IsOptional() + @IsBoolean() + trial?: boolean; +} diff --git a/apps/api-v2/src/sections/applications/questions/dto/update.application-question.dto.ts b/apps/api-v2/src/sections/applications/questions/dto/update.application-question.dto.ts new file mode 100644 index 00000000..afd605b4 --- /dev/null +++ b/apps/api-v2/src/sections/applications/questions/dto/update.application-question.dto.ts @@ -0,0 +1,8 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateApplicationQuestionDto } from './create.application-question.dto'; + +/** + * Every field of a question can be updated on its own, so all fields of the + * create DTO are optional here while keeping their validation rules. + */ +export class UpdateApplicationQuestionDto extends PartialType(CreateApplicationQuestionDto) {} diff --git a/apps/api-v2/src/sections/applications/questions/dto/upsert.application-question.dto.ts b/apps/api-v2/src/sections/applications/questions/dto/upsert.application-question.dto.ts new file mode 100644 index 00000000..61e581a8 --- /dev/null +++ b/apps/api-v2/src/sections/applications/questions/dto/upsert.application-question.dto.ts @@ -0,0 +1,17 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsUUID } from 'class-validator'; +import { CreateApplicationQuestionDto } from './create.application-question.dto'; + +/** + * A single entry of a bulk upsert. Every question is sent in full: entries with + * an ID replace the matching question, entries without one are created. + */ +export class UpsertApplicationQuestionDto extends CreateApplicationQuestionDto { + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the question to update. Omit to create a new question.', + }) + @IsOptional() + @IsUUID() + id?: string; +} diff --git a/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts b/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts index 30def343..d2dd706e 100644 --- a/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts +++ b/apps/api-v2/test/sections/applications/questions/application-questions.controller.spec.ts @@ -1,4 +1,5 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { ApplicationQuestionType } from '@repo/db'; import { Request } from 'express'; import { ApplicationQuestionsController } from 'src/sections/applications/questions/application-questions.controller'; import { ApplicationQuestionsService } from 'src/sections/applications/questions/application-questions.service'; @@ -7,12 +8,26 @@ describe('ApplicationQuestionsController', () => { let applicationQuestionsController: ApplicationQuestionsController; let applicationQuestionsService: { findAll: jest.Mock; + create: jest.Mock; + update: jest.Mock; + upsertMany: jest.Mock; delete: jest.Mock; }; + const question = { + title: 'What is your experience?', + subtitle: 'Tell us about your past projects and roles.', + type: ApplicationQuestionType.TEXT, + icon: 'briefcase', + sort: 1, + }; + beforeEach(async () => { applicationQuestionsService = { findAll: jest.fn(), + create: jest.fn(), + update: jest.fn(), + upsertMany: jest.fn(), delete: jest.fn(), }; @@ -62,6 +77,50 @@ describe('ApplicationQuestionsController', () => { }); }); + describe('createApplicationQuestion', () => { + it('should create the question for the authenticated team', async () => { + applicationQuestionsService.create.mockResolvedValue({ id: 'question-1' }); + + const req = { token: { id: 'team-123' } } as Request; + + const result = await applicationQuestionsController.createApplicationQuestion(question, req); + + expect(applicationQuestionsService.create).toHaveBeenCalledWith(question, 'team-123'); + expect(result).toEqual({ id: 'question-1' }); + }); + }); + + describe('upsertApplicationQuestions', () => { + it('should upsert the questions for the authenticated team', async () => { + applicationQuestionsService.upsertMany.mockResolvedValue([{ id: 'question-1' }]); + + const questions = [{ ...question, id: 'question-1' }]; + const req = { token: { id: 'team-123' } } as Request; + + const result = await applicationQuestionsController.upsertApplicationQuestions(questions, req); + + expect(applicationQuestionsService.upsertMany).toHaveBeenCalledWith(questions, 'team-123'); + expect(result).toEqual([{ id: 'question-1' }]); + }); + }); + + describe('updateApplicationQuestion', () => { + it('should update the question for the authenticated team', async () => { + applicationQuestionsService.update.mockResolvedValue({ id: 'question-1', title: 'Updated' }); + + const req = { token: { id: 'team-123' } } as Request; + + const result = await applicationQuestionsController.updateApplicationQuestion( + 'question-1', + { title: 'Updated' }, + req, + ); + + expect(applicationQuestionsService.update).toHaveBeenCalledWith('question-1', { title: 'Updated' }, 'team-123'); + expect(result).toEqual({ id: 'question-1', title: 'Updated' }); + }); + }); + describe('deleteApplicationQuestion', () => { it('should delete the question for the authenticated team', async () => { applicationQuestionsService.delete.mockResolvedValue(undefined); diff --git a/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts b/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts index 470d647b..43c84b79 100644 --- a/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts +++ b/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts @@ -1,18 +1,41 @@ -import { NotFoundException } from '@nestjs/common'; -import { ApplicationQuestionsService } from 'src/sections/applications/questions/application-questions.service'; +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ApplicationQuestionType } from '@repo/db'; import { PrismaService } from 'src/common/db/prisma.service'; +import { ApplicationQuestionsService } from 'src/sections/applications/questions/application-questions.service'; describe('ApplicationQuestionsService', () => { let applicationQuestionsService: ApplicationQuestionsService; let prismaService: { + $transaction: jest.Mock; applicationQuestion: { + findMany: jest.Mock; + count: jest.Mock; + create: jest.Mock; + update: jest.Mock; + updateMany: jest.Mock; + findUnique: jest.Mock; deleteMany: jest.Mock; }; }; + const question = { + title: 'What is your experience?', + subtitle: 'Tell us about your past projects and roles.', + type: ApplicationQuestionType.TEXT, + icon: 'briefcase', + sort: 1, + }; + beforeEach(() => { prismaService = { + $transaction: jest.fn(), applicationQuestion: { + findMany: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + updateMany: jest.fn(), + findUnique: jest.fn(), deleteMany: jest.fn(), }, }; @@ -20,6 +43,131 @@ describe('ApplicationQuestionsService', () => { applicationQuestionsService = new ApplicationQuestionsService(prismaService as unknown as PrismaService); }); + describe('findAll', () => { + it('should apply pagination, sorting, filter, and build team constraints', async () => { + prismaService.applicationQuestion.findMany.mockResolvedValue([{ id: 'question-1' }]); + prismaService.applicationQuestion.count.mockResolvedValue(4); + + const result = await applicationQuestionsService.findAll( + { page: 2, limit: 2 }, + 'sort', + 'desc', + { required: true }, + 'team-123', + ); + + expect(prismaService.applicationQuestion.findMany).toHaveBeenCalledWith({ + where: { required: true, buildTeamId: 'team-123' }, + orderBy: { sort: 'desc' }, + skip: 2, + take: 2, + }); + expect(result).toEqual({ + data: [{ id: 'question-1' }], + meta: { page: 2, perPage: 2, totalItems: 4, totalPages: 2 }, + }); + }); + }); + + describe('create', () => { + it('should create the question for the given team', async () => { + prismaService.applicationQuestion.create.mockResolvedValue({ id: 'question-1' }); + + const result = await applicationQuestionsService.create(question, 'team-123'); + + expect(prismaService.applicationQuestion.create).toHaveBeenCalledWith({ + data: { ...question, buildTeamId: 'team-123' }, + }); + expect(result).toEqual({ id: 'question-1' }); + }); + }); + + describe('update', () => { + it('should only update questions of the given team', async () => { + prismaService.applicationQuestion.updateMany.mockResolvedValue({ count: 1 }); + prismaService.applicationQuestion.findUnique.mockResolvedValue({ id: 'question-1', title: 'Updated' }); + + const result = await applicationQuestionsService.update('question-1', { title: 'Updated' }, 'team-123'); + + expect(prismaService.applicationQuestion.updateMany).toHaveBeenCalledWith({ + where: { id: 'question-1', buildTeamId: 'team-123' }, + data: { title: 'Updated' }, + }); + expect(result).toEqual({ id: 'question-1', title: 'Updated' }); + }); + + it('should throw when the question does not belong to the team', async () => { + prismaService.applicationQuestion.updateMany.mockResolvedValue({ count: 0 }); + + await expect(applicationQuestionsService.update('question-1', { title: 'Updated' }, 'team-123')).rejects.toThrow( + NotFoundException, + ); + expect(prismaService.applicationQuestion.findUnique).not.toHaveBeenCalled(); + }); + }); + + describe('upsertMany', () => { + beforeEach(() => { + prismaService.$transaction.mockImplementation(async (operations: unknown[]) => await Promise.all(operations)); + }); + + it('should update existing questions and create new ones', async () => { + prismaService.applicationQuestion.findMany.mockResolvedValue([{ id: 'question-1', buildTeamId: 'team-123' }]); + prismaService.applicationQuestion.update.mockResolvedValue({ id: 'question-1' }); + prismaService.applicationQuestion.create.mockResolvedValue({ id: 'question-2' }); + + const result = await applicationQuestionsService.upsertMany( + [ + { ...question, id: 'question-1' }, + { ...question, sort: 2 }, + ], + 'team-123', + ); + + expect(prismaService.applicationQuestion.findMany).toHaveBeenCalledWith({ + where: { id: { in: ['question-1'] } }, + select: { id: true, buildTeamId: true }, + }); + expect(prismaService.applicationQuestion.update).toHaveBeenCalledWith({ + where: { id: 'question-1' }, + data: question, + }); + expect(prismaService.applicationQuestion.create).toHaveBeenCalledWith({ + data: { ...question, sort: 2, buildTeamId: 'team-123' }, + }); + expect(result).toEqual([{ id: 'question-1' }, { id: 'question-2' }]); + }); + + it('should create a question with the given id when it does not exist yet', async () => { + prismaService.applicationQuestion.findMany.mockResolvedValue([]); + prismaService.applicationQuestion.create.mockResolvedValue({ id: 'question-9' }); + + await applicationQuestionsService.upsertMany([{ ...question, id: 'question-9' }], 'team-123'); + + expect(prismaService.applicationQuestion.update).not.toHaveBeenCalled(); + expect(prismaService.applicationQuestion.create).toHaveBeenCalledWith({ + data: { ...question, id: 'question-9', buildTeamId: 'team-123' }, + }); + }); + + it('should not look up ids when every question is new', async () => { + prismaService.applicationQuestion.create.mockResolvedValue({ id: 'question-1' }); + + await applicationQuestionsService.upsertMany([question], 'team-123'); + + expect(prismaService.applicationQuestion.findMany).not.toHaveBeenCalled(); + }); + + it('should refuse to touch questions of another team', async () => { + prismaService.applicationQuestion.findMany.mockResolvedValue([{ id: 'question-1', buildTeamId: 'other-team' }]); + + await expect( + applicationQuestionsService.upsertMany([{ ...question, id: 'question-1' }], 'team-123'), + ).rejects.toThrow(ForbiddenException); + expect(prismaService.$transaction).not.toHaveBeenCalled(); + }); + }); + describe('delete', () => { it('should delete the question for the given question and team ids', async () => { prismaService.applicationQuestion.deleteMany.mockResolvedValue({ count: 1 }); From 6c0a9a81ad8fc58219a3e2608a0b12333dd28dc1 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:20:08 +0200 Subject: [PATCH 12/21] feat(api/v2): :sparkles: Implement GET /:teamId/applications/questions Returns the application questions of a single team. The route is public, because the application form has to render before the applicant is part of the team, which matches how v1 exposed the same data. The team is resolved by ID, or by slug when `?slug=true` is passed, and an unknown team yields a 404 so that "no such team" stays distinguishable from "team without questions". Results default to sorting by `sort`, the field that drives the order of the form itself. It lives on its own controller since a Nest controller cannot escape its own path prefix. Co-Authored-By: Claude Opus 5 --- .../questions/application-questions.module.ts | 3 +- .../application-questions.service.ts | 30 +++++++ .../team-application-questions.controller.ts | 63 +++++++++++++++ .../application-questions.service.spec.ts | 56 +++++++++++++ ...m-application-questions.controller.spec.ts | 80 +++++++++++++++++++ 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 apps/api-v2/src/sections/applications/questions/team-application-questions.controller.ts create mode 100644 apps/api-v2/test/sections/applications/questions/team-application-questions.controller.spec.ts diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.module.ts b/apps/api-v2/src/sections/applications/questions/application-questions.module.ts index bd8d3d96..38498d54 100644 --- a/apps/api-v2/src/sections/applications/questions/application-questions.module.ts +++ b/apps/api-v2/src/sections/applications/questions/application-questions.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { PrismaService } from 'src/common/db/prisma.service'; import { ApplicationQuestionsController } from './application-questions.controller'; import { ApplicationQuestionsService } from './application-questions.service'; +import { TeamApplicationQuestionsController } from './team-application-questions.controller'; @Module({ - controllers: [ApplicationQuestionsController], + controllers: [ApplicationQuestionsController, TeamApplicationQuestionsController], providers: [ApplicationQuestionsService, PrismaService], }) export class ApplicationQuestionsModule {} diff --git a/apps/api-v2/src/sections/applications/questions/application-questions.service.ts b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts index 641a1ee7..0a60968e 100644 --- a/apps/api-v2/src/sections/applications/questions/application-questions.service.ts +++ b/apps/api-v2/src/sections/applications/questions/application-questions.service.ts @@ -50,6 +50,36 @@ export class ApplicationQuestionsService { }; } + /** + * Finds all questions of the team with the given ID or slug. Used for the public + * application form, which is rendered before a user belongs to the team. + * @param teamId ID of the team, or its slug when useSlug is set. + * @param useSlug Whether teamId should be treated as a slug instead of an ID. + * @param pagination Pagination parameters. + * @param sortBy Field to sort by. + * @param order Order of sorting (asc/desc). + * @returns A paginated response containing the questions and metadata. + * @throws NotFoundException if no team with the given ID or slug exists. + */ + async findAllForTeam( + teamId: string, + useSlug: boolean, + pagination: PaginationParams, + sortBy?: SortingParams['sortBy'], + order?: SortingParams['order'], + ) { + const buildTeam = await this.prisma.buildTeam.findUnique({ + where: useSlug ? { slug: teamId } : { id: teamId }, + select: { id: true }, + }); + + if (!buildTeam) { + throw new NotFoundException('BuildTeam not found'); + } + + return await this.findAll(pagination, sortBy, order, {}, buildTeam.id); + } + /** * Creates a new question for the given team. * @param question The question to create. diff --git a/apps/api-v2/src/sections/applications/questions/team-application-questions.controller.ts b/apps/api-v2/src/sections/applications/questions/team-application-questions.controller.ts new file mode 100644 index 00000000..2a2276bc --- /dev/null +++ b/apps/api-v2/src/sections/applications/questions/team-application-questions.controller.ts @@ -0,0 +1,63 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiOperation, ApiParam } from '@nestjs/swagger'; +import { ApiErrorResponse, ApiPaginatedResponseDto } from 'src/common/decorators/api-response.decorator'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { Paginated } from 'src/common/decorators/paginated.decorator'; +import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { SkipAuth } from 'src/common/decorators/skip-auth.decorator'; +import { Sortable } from 'src/common/decorators/sortable.decorator'; +import { Sorting, SortingParams } from 'src/common/decorators/sorting.decorator'; +import { PaginatedControllerResponse } from 'src/typings'; +import { ApplicationQuestionsService } from './application-questions.service'; +import { ApplicationQuestionDto } from './dto/application-question.dto'; + +/** + * The questions of a single team. This is public because the application form has + * to be rendered before the applicant is part of the team. + */ +@Controller(':teamId/applications/questions') +export class TeamApplicationQuestionsController { + constructor(private readonly applicationQuestionsService: ApplicationQuestionsService) {} + + /** + * Returns all application questions of the team with the given ID. + */ + @Get('/') + @SkipAuth() + @Sortable({ + defaultSortBy: 'sort', + allowedFields: ['title', 'id', 'subtitle', 'placeholder', 'required', 'sort', 'type', 'icon', 'trial'], + defaultOrder: 'asc', + }) + @Paginated() + @ApiOperation({ + summary: 'Get All Application Questions Of A Team', + description: 'Returns all application questions of the team with the given ID.', + }) + @ApiParam({ + name: 'teamId', + description: 'The ID of the build team, or its slug when the slug query parameter is set.', + }) + @Filtered({ + fields: [{ name: 'slug', required: false, type: Boolean }], + }) + @ApiPaginatedResponseDto(ApplicationQuestionDto, { description: 'Success' }) + @ApiErrorResponse({ status: 404, description: 'BuildTeam not found' }) + async getTeamApplicationQuestions( + @Param('teamId') teamId: string, + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + ): PaginatedControllerResponse { + const { slug }: { slug?: boolean } = filter.filter; + + return await this.applicationQuestionsService.findAllForTeam( + teamId, + Boolean(slug), + pagination, + sorting.sortBy, + sorting.order, + ); + } +} diff --git a/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts b/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts index 43c84b79..150501f9 100644 --- a/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts +++ b/apps/api-v2/test/sections/applications/questions/application-questions.service.spec.ts @@ -16,6 +16,9 @@ describe('ApplicationQuestionsService', () => { findUnique: jest.Mock; deleteMany: jest.Mock; }; + buildTeam: { + findUnique: jest.Mock; + }; }; const question = { @@ -38,6 +41,9 @@ describe('ApplicationQuestionsService', () => { findUnique: jest.fn(), deleteMany: jest.fn(), }, + buildTeam: { + findUnique: jest.fn(), + }, }; applicationQuestionsService = new ApplicationQuestionsService(prismaService as unknown as PrismaService); @@ -69,6 +75,56 @@ describe('ApplicationQuestionsService', () => { }); }); + describe('findAllForTeam', () => { + it('should resolve the team by id and return its questions', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-123' }); + prismaService.applicationQuestion.findMany.mockResolvedValue([{ id: 'question-1' }]); + prismaService.applicationQuestion.count.mockResolvedValue(1); + + const result = await applicationQuestionsService.findAllForTeam( + 'team-123', + false, + { page: 1, limit: 20 }, + 'sort', + 'asc', + ); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith({ + where: { id: 'team-123' }, + select: { id: true }, + }); + expect(prismaService.applicationQuestion.findMany).toHaveBeenCalledWith({ + where: { buildTeamId: 'team-123' }, + orderBy: { sort: 'asc' }, + skip: 0, + take: 20, + }); + expect(result.data).toEqual([{ id: 'question-1' }]); + }); + + it('should resolve the team by slug when requested', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-123' }); + prismaService.applicationQuestion.findMany.mockResolvedValue([]); + prismaService.applicationQuestion.count.mockResolvedValue(0); + + await applicationQuestionsService.findAllForTeam('my-team', true, { page: 1, limit: 20 }); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith({ + where: { slug: 'my-team' }, + select: { id: true }, + }); + }); + + it('should throw when the team does not exist', async () => { + prismaService.buildTeam.findUnique.mockResolvedValue(null); + + await expect( + applicationQuestionsService.findAllForTeam('missing', false, { page: 1, limit: 20 }), + ).rejects.toThrow(NotFoundException); + expect(prismaService.applicationQuestion.findMany).not.toHaveBeenCalled(); + }); + }); + describe('create', () => { it('should create the question for the given team', async () => { prismaService.applicationQuestion.create.mockResolvedValue({ id: 'question-1' }); diff --git a/apps/api-v2/test/sections/applications/questions/team-application-questions.controller.spec.ts b/apps/api-v2/test/sections/applications/questions/team-application-questions.controller.spec.ts new file mode 100644 index 00000000..2ebc4f2b --- /dev/null +++ b/apps/api-v2/test/sections/applications/questions/team-application-questions.controller.spec.ts @@ -0,0 +1,80 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ApplicationQuestionsService } from 'src/sections/applications/questions/application-questions.service'; +import { TeamApplicationQuestionsController } from 'src/sections/applications/questions/team-application-questions.controller'; + +describe('TeamApplicationQuestionsController', () => { + let teamApplicationQuestionsController: TeamApplicationQuestionsController; + let applicationQuestionsService: { + findAllForTeam: jest.Mock; + }; + + beforeEach(async () => { + applicationQuestionsService = { + findAllForTeam: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [TeamApplicationQuestionsController], + providers: [ + { + provide: ApplicationQuestionsService, + useValue: applicationQuestionsService, + }, + ], + }).compile(); + + teamApplicationQuestionsController = module.get( + TeamApplicationQuestionsController, + ); + }); + + describe('getTeamApplicationQuestions', () => { + it('should request the questions of the team in the path', async () => { + applicationQuestionsService.findAllForTeam.mockResolvedValue({ + data: [{ id: 'question-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + + const pagination = { page: 1, limit: 20 }; + const sorting = { sortBy: 'sort', order: 'asc' }; + + const result = await teamApplicationQuestionsController.getTeamApplicationQuestions( + 'team-123', + pagination as never, + sorting as never, + { filter: {} } as never, + ); + + expect(applicationQuestionsService.findAllForTeam).toHaveBeenCalledWith( + 'team-123', + false, + pagination, + 'sort', + 'asc', + ); + expect(result).toEqual({ + data: [{ id: 'question-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + + it('should forward the slug flag when it is set', async () => { + applicationQuestionsService.findAllForTeam.mockResolvedValue({ data: [], meta: {} }); + + await teamApplicationQuestionsController.getTeamApplicationQuestions( + 'my-team', + { page: 1, limit: 20 } as never, + { sortBy: 'sort', order: 'asc' } as never, + { filter: { slug: true } } as never, + ); + + expect(applicationQuestionsService.findAllForTeam).toHaveBeenCalledWith( + 'my-team', + true, + { page: 1, limit: 20 }, + 'sort', + 'asc', + ); + }); + }); +}); From 678ead506b9e1498e4386a6e045e383687b3f71f Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:20:34 +0200 Subject: [PATCH 13/21] feat(api/v2): :sparkles: Implement application response template routes Adds the last group of routes of #59: GET /applications/templates POST /applications/templates PUT /applications/templates/:id DELETE /applications/templates/:id Templates are the canned replies teams send when reviewing an application. Like the question routes, every operation is scoped to the authenticated team, and touching a template of another team yields a 404 instead of silently succeeding. Note that ApplicationResponseTemplate spells its foreign key `buildteamId`, unlike ApplicationQuestion which uses `buildTeamId`. The new module is registered in front of ApplicationsModule, because routes resolve in module registration order and /applications/:id would otherwise match /applications/templates first. Co-Authored-By: Claude Opus 5 --- apps/api-v2/src/app.module.ts | 9 +- .../application-templates.controller.ts | 121 +++++++++++++++++ .../templates/application-templates.module.ts | 10 ++ .../application-templates.service.ts | 109 +++++++++++++++ .../templates/dto/application-template.dto.ts | 27 ++++ .../dto/create.application-template.dto.ts | 20 +++ .../dto/update.application-template.dto.ts | 8 ++ .../application-templates.controller.spec.ts | 114 ++++++++++++++++ .../application-templates.service.spec.ts | 127 ++++++++++++++++++ 9 files changed, 543 insertions(+), 2 deletions(-) create mode 100644 apps/api-v2/src/sections/applications/templates/application-templates.controller.ts create mode 100644 apps/api-v2/src/sections/applications/templates/application-templates.module.ts create mode 100644 apps/api-v2/src/sections/applications/templates/application-templates.service.ts create mode 100644 apps/api-v2/src/sections/applications/templates/dto/application-template.dto.ts create mode 100644 apps/api-v2/src/sections/applications/templates/dto/create.application-template.dto.ts create mode 100644 apps/api-v2/src/sections/applications/templates/dto/update.application-template.dto.ts create mode 100644 apps/api-v2/test/sections/applications/templates/application-templates.controller.spec.ts create mode 100644 apps/api-v2/test/sections/applications/templates/application-templates.service.spec.ts diff --git a/apps/api-v2/src/app.module.ts b/apps/api-v2/src/app.module.ts index 16bf0027..ff303fae 100644 --- a/apps/api-v2/src/app.module.ts +++ b/apps/api-v2/src/app.module.ts @@ -3,16 +3,21 @@ import { ConfigModule } from '@nestjs/config'; import { APP_GUARD } from '@nestjs/core'; import { PrismaService } from './common/db/prisma.service'; import { AuthGuard } from './common/guards/auth.guard'; +import { ApplicationQuestionsModule } from './sections/applications/questions/application-questions.module'; import { ApplicationsModule } from './sections/applications/applications.module'; +import { ApplicationTemplatesModule } from './sections/applications/templates/application-templates.module'; import { AuthModule } from './sections/auth/auth.module'; +import { ClaimsModule } from './sections/claims/claims.module'; import { StatusModule } from './sections/status/status.module'; import { UtilityModule } from './sections/utility/utility.module'; -import { ClaimsModule } from './sections/claims/claims.module'; -import { ApplicationQuestionsModule } from './sections/applications/questions/application-questions.module'; @Module({ imports: [ + // Routes are matched in the order their modules are registered, so both of these + // have to stay in front of ApplicationsModule. Otherwise /applications/:id would + // swallow /applications/questions and /applications/templates. ApplicationQuestionsModule, + ApplicationTemplatesModule, ApplicationsModule, AuthModule, ClaimsModule, diff --git a/apps/api-v2/src/sections/applications/templates/application-templates.controller.ts b/apps/api-v2/src/sections/applications/templates/application-templates.controller.ts new file mode 100644 index 00000000..be32bcbf --- /dev/null +++ b/apps/api-v2/src/sections/applications/templates/application-templates.controller.ts @@ -0,0 +1,121 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiResponse } from '@nestjs/swagger'; +import { Request } from 'express'; +import { + ApiDefaultResponse, + ApiErrorResponse, + ApiPaginatedResponseDto, +} from 'src/common/decorators/api-response.decorator'; +import { Filter, FilterParams } from 'src/common/decorators/filter.decorator'; +import { Filtered } from 'src/common/decorators/filtered.decorator'; +import { Paginated } from 'src/common/decorators/paginated.decorator'; +import { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { Sortable } from 'src/common/decorators/sortable.decorator'; +import { Sorting, SortingParams } from 'src/common/decorators/sorting.decorator'; +import { ControllerResponse, PaginatedControllerResponse } from 'src/typings'; +import { ApplicationTemplatesService } from './application-templates.service'; +import { ApplicationTemplateDto } from './dto/application-template.dto'; +import { CreateApplicationTemplateDto } from './dto/create.application-template.dto'; +import { UpdateApplicationTemplateDto } from './dto/update.application-template.dto'; + +@Controller('applications/templates') +export class ApplicationTemplatesController { + constructor(private readonly applicationTemplatesService: ApplicationTemplatesService) {} + + /** + * Returns all response templates of the currently authenticated team. + */ + @Get('/') + @ApiBearerAuth() + @Sortable({ + defaultSortBy: 'name', + allowedFields: ['id', 'name', 'content'], + defaultOrder: 'asc', + }) + @Paginated() + @ApiOperation({ + summary: 'Get All Response Templates', + description: 'Returns all response templates of the currently authenticated team.', + }) + @Filtered({ + fields: [ + { name: 'name', required: false, type: String }, + { name: 'content', required: false, type: String }, + ], + }) + @ApiPaginatedResponseDto(ApplicationTemplateDto, { description: 'Success' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + async getApplicationTemplates( + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + @Req() req: Request, + ): PaginatedControllerResponse { + return await this.applicationTemplatesService.findAll( + pagination, + sorting.sortBy, + sorting.order, + filter.filter, + req.token.id, + ); + } + + /** + * Creates a new response template for the currently authenticated team. + */ + @Post('/') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create Response Template', + description: 'Creates a new response template for the currently authenticated team.', + }) + @ApiDefaultResponse(ApplicationTemplateDto, { + status: 201, + description: 'Template created successfully.', + }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + async createApplicationTemplate( + @Body() createApplicationTemplateDto: CreateApplicationTemplateDto, + @Req() req: Request, + ): ControllerResponse { + return await this.applicationTemplatesService.create(createApplicationTemplateDto, req.token.id); + } + + /** + * Updates the response template with the given ID if it belongs to the currently authenticated team. + */ + @Put(':id') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update Response Template', + description: 'Updates the response template with the given ID if it belongs to the currently authenticated team.', + }) + @ApiDefaultResponse(ApplicationTemplateDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Template not found' }) + async updateApplicationTemplate( + @Param('id') id: string, + @Body() updateApplicationTemplateDto: UpdateApplicationTemplateDto, + @Req() req: Request, + ): ControllerResponse { + return await this.applicationTemplatesService.update(id, updateApplicationTemplateDto, req.token.id); + } + + /** + * Deletes the response template with the given ID if it belongs to the currently authenticated team. + */ + @Delete(':id') + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete Response Template', + description: 'Deletes the response template with the given ID if it belongs to the currently authenticated team.', + }) + @ApiResponse({ status: 200, description: 'Template deleted successfully.' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Template not found' }) + async deleteApplicationTemplate(@Param('id') id: string, @Req() req: Request): ControllerResponse { + return await this.applicationTemplatesService.delete(id, req.token.id); + } +} diff --git a/apps/api-v2/src/sections/applications/templates/application-templates.module.ts b/apps/api-v2/src/sections/applications/templates/application-templates.module.ts new file mode 100644 index 00000000..e565ec77 --- /dev/null +++ b/apps/api-v2/src/sections/applications/templates/application-templates.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ApplicationTemplatesController } from './application-templates.controller'; +import { ApplicationTemplatesService } from './application-templates.service'; + +@Module({ + controllers: [ApplicationTemplatesController], + providers: [ApplicationTemplatesService, PrismaService], +}) +export class ApplicationTemplatesModule {} diff --git a/apps/api-v2/src/sections/applications/templates/application-templates.service.ts b/apps/api-v2/src/sections/applications/templates/application-templates.service.ts new file mode 100644 index 00000000..606b8306 --- /dev/null +++ b/apps/api-v2/src/sections/applications/templates/application-templates.service.ts @@ -0,0 +1,109 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { FilterParams } from 'src/common/decorators/filter.decorator'; +import { PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { SortingParams } from 'src/common/decorators/sorting.decorator'; +import { CreateApplicationTemplateDto } from './dto/create.application-template.dto'; +import { UpdateApplicationTemplateDto } from './dto/update.application-template.dto'; + +@Injectable() +export class ApplicationTemplatesService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Finds all response templates based on pagination, sorting, and filtering parameters. + * @param pagination Pagination parameters. + * @param sortBy Field to sort by. + * @param order Order of sorting (asc/desc). + * @param filter Filter parameters. + * @param buildteamId ID of the team to filter templates by. + * @returns A paginated response containing the templates and metadata. + */ + async findAll( + pagination: PaginationParams, + sortBy?: SortingParams['sortBy'], + order?: SortingParams['order'], + filter?: FilterParams['filter'], + buildteamId?: string, + ) { + const sortField = sortBy || 'name'; + const sortOrder = order === 'desc' ? 'desc' : 'asc'; + + const take = Math.max(Number(pagination.limit) || 20, 1); + const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; + + const combinedFilter = { + ...filter, + ...(buildteamId ? { buildteamId } : {}), + }; + + const [templates, count] = await Promise.all([ + this.prisma.applicationResponseTemplate.findMany({ + where: combinedFilter, + orderBy: { [sortField]: sortOrder }, + skip, + take, + }), + this.prisma.applicationResponseTemplate.count({ where: combinedFilter }), + ]); + + return { + data: templates, + meta: { + page: pagination.page, + perPage: pagination.limit, + totalItems: count, + totalPages: Math.ceil(count / pagination.limit), + }, + }; + } + + /** + * Creates a new response template for the given team. + * @param template The template to create. + * @param buildteamId ID of the team the template belongs to. + * @returns The created template. + */ + async create(template: CreateApplicationTemplateDto, buildteamId: string) { + return await this.prisma.applicationResponseTemplate.create({ + data: { ...template, buildteamId }, + }); + } + + /** + * Updates a response template if it belongs to the given team. + * @param id ID of the template to update. + * @param template The fields to update. + * @param buildteamId ID of the team the template has to belong to. + * @returns The updated template. + * @throws NotFoundException if the template does not exist or belongs to another team. + */ + async update(id: string, template: UpdateApplicationTemplateDto, buildteamId: string) { + const { count } = await this.prisma.applicationResponseTemplate.updateMany({ + where: { id, buildteamId }, + data: template, + }); + + if (count === 0) { + throw new NotFoundException('Template not found'); + } + + return await this.prisma.applicationResponseTemplate.findUnique({ where: { id } }); + } + + /** + * Deletes a response template if it belongs to the given team. + * @param id ID of the template to delete. + * @param buildteamId ID of the team the template has to belong to. + * @throws NotFoundException if the template does not exist or belongs to another team. + */ + async delete(id: string, buildteamId: string) { + const { count } = await this.prisma.applicationResponseTemplate.deleteMany({ + where: { id, buildteamId }, + }); + + if (count === 0) { + throw new NotFoundException('Template not found'); + } + } +} diff --git a/apps/api-v2/src/sections/applications/templates/dto/application-template.dto.ts b/apps/api-v2/src/sections/applications/templates/dto/application-template.dto.ts new file mode 100644 index 00000000..80fcf5a1 --- /dev/null +++ b/apps/api-v2/src/sections/applications/templates/dto/application-template.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class ApplicationTemplateDto { + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The unique ID of the response template.', + }) + id: string; + + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the build team this template belongs to.', + }) + buildteamId: string; + + @ApiProperty({ + example: 'Response Template', + description: 'The name of the template, used to identify it while reviewing.', + }) + name: string; + + @ApiProperty({ + example: 'Thanks for applying! Unfortunately we cannot accept you at this time.', + description: 'The message that is sent to the applicant.', + }) + content: string; +} diff --git a/apps/api-v2/src/sections/applications/templates/dto/create.application-template.dto.ts b/apps/api-v2/src/sections/applications/templates/dto/create.application-template.dto.ts new file mode 100644 index 00000000..06b46135 --- /dev/null +++ b/apps/api-v2/src/sections/applications/templates/dto/create.application-template.dto.ts @@ -0,0 +1,20 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString } from 'class-validator'; + +export class CreateApplicationTemplateDto { + @ApiPropertyOptional({ + example: 'Response Template', + default: 'Response Template', + description: 'The name of the template, used to identify it while reviewing.', + }) + @IsOptional() + @IsString() + name?: string; + + @ApiProperty({ + example: 'Thanks for applying! Unfortunately we cannot accept you at this time.', + description: 'The message that is sent to the applicant.', + }) + @IsString() + content: string; +} diff --git a/apps/api-v2/src/sections/applications/templates/dto/update.application-template.dto.ts b/apps/api-v2/src/sections/applications/templates/dto/update.application-template.dto.ts new file mode 100644 index 00000000..cbee7a8c --- /dev/null +++ b/apps/api-v2/src/sections/applications/templates/dto/update.application-template.dto.ts @@ -0,0 +1,8 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateApplicationTemplateDto } from './create.application-template.dto'; + +/** + * Both the name and the content of a template can be updated on their own, so all + * fields of the create DTO are optional here while keeping their validation rules. + */ +export class UpdateApplicationTemplateDto extends PartialType(CreateApplicationTemplateDto) {} diff --git a/apps/api-v2/test/sections/applications/templates/application-templates.controller.spec.ts b/apps/api-v2/test/sections/applications/templates/application-templates.controller.spec.ts new file mode 100644 index 00000000..054d442d --- /dev/null +++ b/apps/api-v2/test/sections/applications/templates/application-templates.controller.spec.ts @@ -0,0 +1,114 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { Request } from 'express'; +import { ApplicationTemplatesController } from 'src/sections/applications/templates/application-templates.controller'; +import { ApplicationTemplatesService } from 'src/sections/applications/templates/application-templates.service'; + +describe('ApplicationTemplatesController', () => { + let applicationTemplatesController: ApplicationTemplatesController; + let applicationTemplatesService: { + findAll: jest.Mock; + create: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; + + beforeEach(async () => { + applicationTemplatesService = { + findAll: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ApplicationTemplatesController], + providers: [ + { + provide: ApplicationTemplatesService, + useValue: applicationTemplatesService, + }, + ], + }).compile(); + + applicationTemplatesController = module.get(ApplicationTemplatesController); + }); + + describe('getApplicationTemplates', () => { + it('should request the templates of the authenticated team', async () => { + applicationTemplatesService.findAll.mockResolvedValue({ + data: [{ id: 'template-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + + const pagination = { page: 1, limit: 20 }; + const sorting = { sortBy: 'name', order: 'asc' }; + const filter = { filter: { name: 'Rejection' } }; + const req = { token: { id: 'team-123' } } as Request; + + const result = await applicationTemplatesController.getApplicationTemplates( + pagination as never, + sorting as never, + filter as never, + req, + ); + + expect(applicationTemplatesService.findAll).toHaveBeenCalledWith( + pagination, + 'name', + 'asc', + { name: 'Rejection' }, + 'team-123', + ); + expect(result).toEqual({ + data: [{ id: 'template-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + }); + + describe('createApplicationTemplate', () => { + it('should create the template for the authenticated team', async () => { + applicationTemplatesService.create.mockResolvedValue({ id: 'template-1' }); + + const req = { token: { id: 'team-123' } } as Request; + + const result = await applicationTemplatesController.createApplicationTemplate( + { content: 'Thanks for applying!' }, + req, + ); + + expect(applicationTemplatesService.create).toHaveBeenCalledWith({ content: 'Thanks for applying!' }, 'team-123'); + expect(result).toEqual({ id: 'template-1' }); + }); + }); + + describe('updateApplicationTemplate', () => { + it('should update the template for the authenticated team', async () => { + applicationTemplatesService.update.mockResolvedValue({ id: 'template-1', name: 'Updated' }); + + const req = { token: { id: 'team-123' } } as Request; + + const result = await applicationTemplatesController.updateApplicationTemplate( + 'template-1', + { name: 'Updated' }, + req, + ); + + expect(applicationTemplatesService.update).toHaveBeenCalledWith('template-1', { name: 'Updated' }, 'team-123'); + expect(result).toEqual({ id: 'template-1', name: 'Updated' }); + }); + }); + + describe('deleteApplicationTemplate', () => { + it('should delete the template for the authenticated team', async () => { + applicationTemplatesService.delete.mockResolvedValue(undefined); + + const req = { token: { id: 'team-123' } } as Request; + + const result = await applicationTemplatesController.deleteApplicationTemplate('template-1', req); + + expect(applicationTemplatesService.delete).toHaveBeenCalledWith('template-1', 'team-123'); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/apps/api-v2/test/sections/applications/templates/application-templates.service.spec.ts b/apps/api-v2/test/sections/applications/templates/application-templates.service.spec.ts new file mode 100644 index 00000000..36d783b0 --- /dev/null +++ b/apps/api-v2/test/sections/applications/templates/application-templates.service.spec.ts @@ -0,0 +1,127 @@ +import { NotFoundException } from '@nestjs/common'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ApplicationTemplatesService } from 'src/sections/applications/templates/application-templates.service'; + +describe('ApplicationTemplatesService', () => { + let applicationTemplatesService: ApplicationTemplatesService; + let prismaService: { + applicationResponseTemplate: { + findMany: jest.Mock; + count: jest.Mock; + create: jest.Mock; + updateMany: jest.Mock; + findUnique: jest.Mock; + deleteMany: jest.Mock; + }; + }; + + beforeEach(() => { + prismaService = { + applicationResponseTemplate: { + findMany: jest.fn(), + count: jest.fn(), + create: jest.fn(), + updateMany: jest.fn(), + findUnique: jest.fn(), + deleteMany: jest.fn(), + }, + }; + + applicationTemplatesService = new ApplicationTemplatesService(prismaService as unknown as PrismaService); + }); + + describe('findAll', () => { + it('should apply pagination, sorting, filter, and build team constraints', async () => { + prismaService.applicationResponseTemplate.findMany.mockResolvedValue([{ id: 'template-1' }]); + prismaService.applicationResponseTemplate.count.mockResolvedValue(4); + + const result = await applicationTemplatesService.findAll( + { page: 2, limit: 2 }, + 'name', + 'desc', + { name: 'Rejection' }, + 'team-123', + ); + + expect(prismaService.applicationResponseTemplate.findMany).toHaveBeenCalledWith({ + where: { name: 'Rejection', buildteamId: 'team-123' }, + orderBy: { name: 'desc' }, + skip: 2, + take: 2, + }); + expect(result).toEqual({ + data: [{ id: 'template-1' }], + meta: { page: 2, perPage: 2, totalItems: 4, totalPages: 2 }, + }); + }); + + it('should fall back to sorting by name in ascending order', async () => { + prismaService.applicationResponseTemplate.findMany.mockResolvedValue([]); + prismaService.applicationResponseTemplate.count.mockResolvedValue(0); + + await applicationTemplatesService.findAll({ page: 1, limit: 20 }, undefined, undefined, {}, 'team-123'); + + expect(prismaService.applicationResponseTemplate.findMany).toHaveBeenCalledWith({ + where: { buildteamId: 'team-123' }, + orderBy: { name: 'asc' }, + skip: 0, + take: 20, + }); + }); + }); + + describe('create', () => { + it('should create the template for the given team', async () => { + prismaService.applicationResponseTemplate.create.mockResolvedValue({ id: 'template-1' }); + + const result = await applicationTemplatesService.create({ content: 'Thanks for applying!' }, 'team-123'); + + expect(prismaService.applicationResponseTemplate.create).toHaveBeenCalledWith({ + data: { content: 'Thanks for applying!', buildteamId: 'team-123' }, + }); + expect(result).toEqual({ id: 'template-1' }); + }); + }); + + describe('update', () => { + it('should only update templates of the given team', async () => { + prismaService.applicationResponseTemplate.updateMany.mockResolvedValue({ count: 1 }); + prismaService.applicationResponseTemplate.findUnique.mockResolvedValue({ id: 'template-1', name: 'Updated' }); + + const result = await applicationTemplatesService.update('template-1', { name: 'Updated' }, 'team-123'); + + expect(prismaService.applicationResponseTemplate.updateMany).toHaveBeenCalledWith({ + where: { id: 'template-1', buildteamId: 'team-123' }, + data: { name: 'Updated' }, + }); + expect(result).toEqual({ id: 'template-1', name: 'Updated' }); + }); + + it('should throw when the template does not belong to the team', async () => { + prismaService.applicationResponseTemplate.updateMany.mockResolvedValue({ count: 0 }); + + await expect(applicationTemplatesService.update('template-1', { name: 'Updated' }, 'team-123')).rejects.toThrow( + NotFoundException, + ); + expect(prismaService.applicationResponseTemplate.findUnique).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should delete the template for the given template and team ids', async () => { + prismaService.applicationResponseTemplate.deleteMany.mockResolvedValue({ count: 1 }); + + await expect(applicationTemplatesService.delete('template-1', 'team-123')).resolves.toBeUndefined(); + + expect(prismaService.applicationResponseTemplate.deleteMany).toHaveBeenCalledWith({ + where: { id: 'template-1', buildteamId: 'team-123' }, + }); + }); + + it('should throw when the template does not belong to the team', async () => { + prismaService.applicationResponseTemplate.deleteMany.mockResolvedValue({ count: 0 }); + + await expect(applicationTemplatesService.delete('template-1', 'team-123')).rejects.toThrow(NotFoundException); + }); + }); +}); From 10912c873b0a7980963dc6727bc4bcdf71bdabf5 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:20:49 +0200 Subject: [PATCH 14/21] test(api/v2): :white_check_mark: Cover application route registration order The nested application routes only resolve because their modules are registered ahead of ApplicationsModule. Nothing enforced that ordering, and getting it wrong is silent: /applications/questions simply starts being handled by /applications/:id. This boots the real AppModule against a stubbed Prisma and drives it over HTTP, asserting that each nested route reaches its own controller, that /applications/:id still works, and that the public team route is reachable without a token while the authenticated one is not. Co-Authored-By: Claude Opus 5 --- .../api-v2/test/bootstrap/route-order.spec.ts | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) create mode 100644 apps/api-v2/test/bootstrap/route-order.spec.ts diff --git a/apps/api-v2/test/bootstrap/route-order.spec.ts b/apps/api-v2/test/bootstrap/route-order.spec.ts new file mode 100644 index 00000000..7e278edd --- /dev/null +++ b/apps/api-v2/test/bootstrap/route-order.spec.ts @@ -0,0 +1,119 @@ +import { INestApplication, VersioningType } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { JwtService } from '@nestjs/jwt'; +import { Test, TestingModule } from '@nestjs/testing'; +import request from 'supertest'; +import { AppModule } from 'src/app.module'; +import { PrismaService } from 'src/common/db/prisma.service'; +import { ExceptionsFilter } from 'src/common/interceptors/error.interceptor'; +import { ResponseInterceptor } from 'src/common/interceptors/response.interceptor'; + +/** + * The nested application routes only resolve correctly as long as their modules are + * registered in front of ApplicationsModule, because /applications/:id would + * otherwise match /applications/questions and /applications/templates first. + */ +describe('application route registration', () => { + let app: INestApplication; + let token: string; + let prismaService: { + $connect: jest.Mock; + application: { findUnique: jest.Mock }; + applicationQuestion: { findMany: jest.Mock; count: jest.Mock }; + applicationResponseTemplate: { findMany: jest.Mock; count: jest.Mock }; + buildTeam: { findUnique: jest.Mock }; + }; + + beforeAll(async () => { + process.env.JWT_SECRET = 'test-secret'; + + prismaService = { + $connect: jest.fn(), + application: { findUnique: jest.fn() }, + applicationQuestion: { findMany: jest.fn(), count: jest.fn() }, + applicationResponseTemplate: { findMany: jest.fn(), count: jest.fn() }, + buildTeam: { findUnique: jest.fn() }, + }; + + const moduleRef: TestingModule = await Test.createTestingModule({ imports: [AppModule] }) + .overrideProvider(PrismaService) + .useValue(prismaService) + .compile(); + + app = moduleRef.createNestApplication(); + app.enableVersioning({ type: VersioningType.URI, defaultVersion: '2' }); + app.useGlobalInterceptors(new ResponseInterceptor()); + app.useGlobalFilters(new ExceptionsFilter(app.get(HttpAdapterHost).httpAdapter)); + await app.init(); + + token = await app.get(JwtService).signAsync({ sub: 'team-123', id: 'team-123' }); + }); + + afterAll(async () => { + await app.close(); + delete process.env.JWT_SECRET; + }); + + beforeEach(() => { + jest.clearAllMocks(); + prismaService.applicationQuestion.findMany.mockResolvedValue([{ id: 'question-1' }]); + prismaService.applicationQuestion.count.mockResolvedValue(1); + prismaService.applicationResponseTemplate.findMany.mockResolvedValue([{ id: 'template-1' }]); + prismaService.applicationResponseTemplate.count.mockResolvedValue(1); + prismaService.buildTeam.findUnique.mockResolvedValue({ id: 'team-123' }); + }); + + it('routes /applications/questions to the questions controller', async () => { + const response = await request(app.getHttpServer()) + .get('/v2/applications/questions') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(prismaService.application.findUnique).not.toHaveBeenCalled(); + expect(prismaService.applicationQuestion.findMany).toHaveBeenCalled(); + expect(response.body).toEqual({ + status: 200, + message: 'Success', + data: [{ id: 'question-1' }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + + it('routes /applications/templates to the templates controller', async () => { + const response = await request(app.getHttpServer()) + .get('/v2/applications/templates') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(prismaService.application.findUnique).not.toHaveBeenCalled(); + expect(prismaService.applicationResponseTemplate.findMany).toHaveBeenCalled(); + expect(response.body.data).toEqual([{ id: 'template-1' }]); + }); + + it('still routes /applications/:id to the applications controller', async () => { + prismaService.application.findUnique.mockResolvedValue({ id: 'application-1' }); + + await request(app.getHttpServer()) + .get('/v2/applications/application-1') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(prismaService.application.findUnique).toHaveBeenCalledWith({ + where: { id: 'application-1' }, + }); + }); + + it('serves the team questions route without a token', async () => { + const response = await request(app.getHttpServer()).get('/v2/team-slug/applications/questions').expect(200); + + expect(prismaService.buildTeam.findUnique).toHaveBeenCalledWith({ + where: { id: 'team-slug' }, + select: { id: true }, + }); + expect(response.body.data).toEqual([{ id: 'question-1' }]); + }); + + it('rejects the authenticated questions route without a token', async () => { + await request(app.getHttpServer()).get('/v2/applications/questions').expect(401); + }); +}); From ca11a3e5de6aae077a3d518d6a516a31bdd6ab5b Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:20:57 +0200 Subject: [PATCH 15/21] docs(mono): :memo: Add CLAUDE.md with repo guidance Documents the commands that are not obvious from package.json, the split between the v1 Express API and the v2 Nest API, the conventions a new v2 endpoint has to follow, and the setup steps the test suite depends on. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..36d4e80b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,89 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Repository + +Turborepo monorepo (Yarn 4 workspaces) for BuildTheEarth's web presence: `apps/*` (frontend, dashboard, api, api-v2) and `packages/*` (db, prettier-config, typescript-config). TypeScript only — ignore/do not add JavaScript files. + +## Commands + +All commands run from the repo root. `yarn ws