From 68c26713ef5d0b156c3915dbe85a5ca8c231449d Mon Sep 17 00:00:00 2001 From: Sidney Nemzer Date: Sat, 28 Feb 2026 13:08:49 -0500 Subject: [PATCH 1/5] fix(api/v2): allow app to start without cachet url/token --- apps/api-v2/src/common/db/external/cachet.service.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 6fa87a93..c7fcb292 100644 --- a/apps/api-v2/src/common/db/external/cachet.service.ts +++ b/apps/api-v2/src/common/db/external/cachet.service.ts @@ -9,8 +9,8 @@ import { firstValueFrom } from "rxjs"; @Injectable() export class CachetAPIService { private readonly logger = new Logger(CachetAPIService.name); - private baseURL: string; - private apiToken: string; + private baseURL: string | undefined; + private apiToken: string | undefined; constructor( private readonly http: HttpService, @@ -26,11 +26,11 @@ export class CachetAPIService { } 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"); - }); + this.testConnection().then(() => { + this.logger.log("Cachet API is reachable"); + }); + } } async testConnection(): Promise { From b988d7b8bf316865a60387cf4e64f599dae0b0f3 Mon Sep 17 00:00:00 2001 From: Sidney Nemzer Date: Sat, 28 Feb 2026 13:09:47 -0500 Subject: [PATCH 2/5] fix(api/v2): require JWT_SECRET for app to start --- apps/api-v2/src/sections/auth/auth.module.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/api-v2/src/sections/auth/auth.module.ts b/apps/api-v2/src/sections/auth/auth.module.ts index c0af5519..37c1b930 100644 --- a/apps/api-v2/src/sections/auth/auth.module.ts +++ b/apps/api-v2/src/sections/auth/auth.module.ts @@ -14,7 +14,7 @@ import { AuthService } from "./auth.service"; imports: [ConfigModule], inject: [ConfigService], useFactory: (configService: ConfigService) => ({ - secret: configService.get("JWT_SECRET"), + secret: configService.getOrThrow("JWT_SECRET"), signOptions: { issuer: "api" }, }), }), From f219e0741f9bbafd62f2b35c0eb7b876cabfa5ba Mon Sep 17 00:00:00 2001 From: Sidney Nemzer Date: Sun, 1 Mar 2026 13:23:36 -0500 Subject: [PATCH 3/5] feat(api/v2): :sparkles: implement GET /claims --- apps/api-v2/src/app.module.ts | 8 ++-- .../decorators/api-response.decorator.ts | 6 ++- .../src/sections/claims/claims.controller.ts | 45 +++++++++++++++++++ .../src/sections/claims/claims.module.ts | 9 ++++ .../src/sections/claims/claims.service.ts | 18 ++++++++ .../src/sections/claims/dto/claim.dto.ts | 28 ++++++++++++ 6 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 apps/api-v2/src/sections/claims/claims.controller.ts create mode 100644 apps/api-v2/src/sections/claims/claims.module.ts create mode 100644 apps/api-v2/src/sections/claims/claims.service.ts create mode 100644 apps/api-v2/src/sections/claims/dto/claim.dto.ts diff --git a/apps/api-v2/src/app.module.ts b/apps/api-v2/src/app.module.ts index 422566f7..5154abaa 100644 --- a/apps/api-v2/src/app.module.ts +++ b/apps/api-v2/src/app.module.ts @@ -7,14 +7,16 @@ 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"; @Module({ imports: [ - UtilityModule, - AuthModule, ApplicationsModule, - StatusModule, + AuthModule, + ClaimsModule, ConfigModule.forRoot({ isGlobal: true, cache: true }), + StatusModule, + UtilityModule, ], providers: [PrismaService, { provide: APP_GUARD, useClass: AuthGuard }], }) 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 e6f688ef..2949c56b 100644 --- a/apps/api-v2/src/common/decorators/api-response.decorator.ts +++ b/apps/api-v2/src/common/decorators/api-response.decorator.ts @@ -18,7 +18,7 @@ import { ResponseDto } from "../dto/response.dto"; */ export function ApiDefaultResponse>( model: TModel, - options: ApiResponseOptions = {}, + { isArray, ...options }: ApiResponseOptions & { isArray?: boolean } = {}, ) { return applyDecorators( ApiExtraModels(ResponseDto, model), @@ -30,7 +30,9 @@ export function ApiDefaultResponse>( { $ref: getSchemaPath(ResponseDto) }, { properties: { - data: { $ref: getSchemaPath(model) }, + data: isArray + ? { type: "array", items: { $ref: getSchemaPath(model) } } + : { $ref: getSchemaPath(model) }, }, }, ], diff --git a/apps/api-v2/src/sections/claims/claims.controller.ts b/apps/api-v2/src/sections/claims/claims.controller.ts new file mode 100644 index 00000000..a58e3fcc --- /dev/null +++ b/apps/api-v2/src/sections/claims/claims.controller.ts @@ -0,0 +1,45 @@ +import { Controller, Get, Query } from "@nestjs/common"; +import { ClaimsService } from "./claims.service"; +import { Filtered } from "src/common/decorators/filtered.decorator"; +import { ApiOperation, ApiQuery } from "@nestjs/swagger"; +import { ClaimDto } from "./dto/claim.dto"; +import { ApiDefaultResponse } from "src/common/decorators/api-response.decorator"; +import { Filter, FilterParams } from "src/common/decorators/filter.decorator"; + +@Controller("claims") +export class ClaimsController { + constructor(private readonly claimsService: ClaimsService) {} + + @Get() + @ApiOperation({ + summary: "Get All Claims", + description: "Returns all claims based on provided filters.", + }) + @Filtered({ + fields: [ + { name: "finished", required: false, type: Boolean }, + { name: "active", required: false, type: Boolean }, + { name: "team", required: false, type: String }, + ], + }) + @ApiQuery({ + name: "slug", + required: false, + type: Boolean, + description: "When true, team filters by slug, otherwise it filters by ID", + }) + @ApiDefaultResponse(ClaimDto, { + description: "List of claims matching the filters.", + isArray: true, + }) + findAll(@Filter() filter: FilterParams, @Query("slug") slug?: boolean) { + const { team, ...otherFilters }: { team?: string } = filter.filter; + const teamFilter = (() => { + if (!team) return {}; + if (slug) return { buildTeam: { slug: team } }; + return { buildTeamId: team }; + })(); + + return this.claimsService.findAll({ ...otherFilters, ...teamFilter }); + } +} diff --git a/apps/api-v2/src/sections/claims/claims.module.ts b/apps/api-v2/src/sections/claims/claims.module.ts new file mode 100644 index 00000000..670dd632 --- /dev/null +++ b/apps/api-v2/src/sections/claims/claims.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { ClaimsService } from './claims.service'; +import { ClaimsController } from './claims.controller'; + +@Module({ + controllers: [ClaimsController], + providers: [ClaimsService], +}) +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 new file mode 100644 index 00000000..23c05ddf --- /dev/null +++ b/apps/api-v2/src/sections/claims/claims.service.ts @@ -0,0 +1,18 @@ +import { Injectable } from "@nestjs/common"; +import { FilterParams } from "src/common/decorators/filter.decorator"; +import { PrismaService } from "src/common/db/prisma.service"; + +@Injectable() +export class ClaimsService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(filter: FilterParams["filter"]) { + return this.prisma.claim.findMany({ + where: filter, + include: { + _count: { select: { builders: true, images: true } }, + images: { select: { id: true, name: true, hash: true } }, + }, + }); + } +} diff --git a/apps/api-v2/src/sections/claims/dto/claim.dto.ts b/apps/api-v2/src/sections/claims/dto/claim.dto.ts new file mode 100644 index 00000000..92774baf --- /dev/null +++ b/apps/api-v2/src/sections/claims/dto/claim.dto.ts @@ -0,0 +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; + + _count: { + builders: number; + images: number; + }; + + images: { + id: string; + name: string; + hash: string; + }[]; +} From b02e641c855c3246e3c16c418cd5373b89ac9474 Mon Sep 17 00:00:00 2001 From: Nudesuppe42 Date: Sun, 1 Mar 2026 19:57:48 +0100 Subject: [PATCH 4/5] fix(api/claims): :bug: Add prisma service to claims module --- apps/api-v2/src/sections/claims/claims.module.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/api-v2/src/sections/claims/claims.module.ts b/apps/api-v2/src/sections/claims/claims.module.ts index 670dd632..3e73d73f 100644 --- a/apps/api-v2/src/sections/claims/claims.module.ts +++ b/apps/api-v2/src/sections/claims/claims.module.ts @@ -1,9 +1,10 @@ -import { Module } from '@nestjs/common'; -import { ClaimsService } from './claims.service'; -import { ClaimsController } from './claims.controller'; +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], + providers: [ClaimsService, PrismaService], }) export class ClaimsModule {} From 7a1ba9f78e7d3b37eb1e40321a7e2c884cf60549 Mon Sep 17 00:00:00 2001 From: Sidney Nemzer Date: Sun, 1 Mar 2026 20:54:07 -0500 Subject: [PATCH 5/5] feat(api/v2): refactor to use authenticated team as default filter --- .../decorators/optional-auth.decorator.ts | 18 +++++++++++ apps/api-v2/src/common/guards/auth.guard.ts | 13 ++++++++ .../src/sections/claims/claims.controller.ts | 30 +++++++++++-------- 3 files changed, 49 insertions(+), 12 deletions(-) create mode 100644 apps/api-v2/src/common/decorators/optional-auth.decorator.ts diff --git a/apps/api-v2/src/common/decorators/optional-auth.decorator.ts b/apps/api-v2/src/common/decorators/optional-auth.decorator.ts new file mode 100644 index 00000000..0eba282b --- /dev/null +++ b/apps/api-v2/src/common/decorators/optional-auth.decorator.ts @@ -0,0 +1,18 @@ +import { SetMetadata } from "@nestjs/common"; + +export const IS_AUTH_OPTIONAL_KEY = "isAuthOptional"; + +/** + * Decorator that makes authentication optional for a route. By default, routes + * require a valid JWT token. + * + * @example + * + * @OptionalAuth() + * @Get('optional-auth-endpoint') + * async getOptionalAuthData(@Request() req: Request) { + * // req.token may be undefined if no valid token is provided + * } + * + */ +export const OptionalAuth = () => SetMetadata(IS_AUTH_OPTIONAL_KEY, true); diff --git a/apps/api-v2/src/common/guards/auth.guard.ts b/apps/api-v2/src/common/guards/auth.guard.ts index 80d00be6..1e3bc372 100644 --- a/apps/api-v2/src/common/guards/auth.guard.ts +++ b/apps/api-v2/src/common/guards/auth.guard.ts @@ -8,6 +8,7 @@ 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( @@ -31,16 +32,28 @@ export class AuthGuard implements CanActivate { return true; } + 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 (!token && isOptional) { + return true; + } + 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; diff --git a/apps/api-v2/src/sections/claims/claims.controller.ts b/apps/api-v2/src/sections/claims/claims.controller.ts index a58e3fcc..df9a390f 100644 --- a/apps/api-v2/src/sections/claims/claims.controller.ts +++ b/apps/api-v2/src/sections/claims/claims.controller.ts @@ -1,40 +1,46 @@ -import { Controller, Get, Query } from "@nestjs/common"; +import { Controller, Get, Req } from "@nestjs/common"; import { ClaimsService } from "./claims.service"; import { Filtered } from "src/common/decorators/filtered.decorator"; -import { ApiOperation, ApiQuery } from "@nestjs/swagger"; +import { ApiBearerAuth, ApiOperation } from "@nestjs/swagger"; import { ClaimDto } from "./dto/claim.dto"; import { ApiDefaultResponse } 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"; @Controller("claims") export class ClaimsController { constructor(private readonly claimsService: ClaimsService) {} @Get() + @OptionalAuth() + @ApiBearerAuth() @ApiOperation({ summary: "Get All Claims", - description: "Returns all claims based on provided filters.", + 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 }, ], }) - @ApiQuery({ - name: "slug", - required: false, - type: Boolean, - description: "When true, team filters by slug, otherwise it filters by ID", - }) @ApiDefaultResponse(ClaimDto, { description: "List of claims matching the filters.", isArray: true, }) - findAll(@Filter() filter: FilterParams, @Query("slug") slug?: boolean) { - const { team, ...otherFilters }: { team?: string } = filter.filter; - const teamFilter = (() => { + findAll(@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 };