From 5b86480238e706be01bc5aa96fe2f6cf1fb7bfe5 Mon Sep 17 00:00:00 2001 From: Kyan Van den Eynde <66461508+kyanvde@users.noreply.github.com> Date: Fri, 28 Aug 2026 21:52:51 +0200 Subject: [PATCH] feat(api/v2): :sparkles: Implement claim routes on a worker queue Fills in the claims section: the paginated and GeoJSON listings, a single claim by ID or by the ID its team uses, create, bulk import, update and delete. Reads are public, writes are scoped to the authenticated team, and every route exists bare and behind a :teamId prefix. Adds QueueService, which hands slow or external work to apps/worker over the BullMQ queue it already consumes. Reverse geocoding, building counts, Discord messages and build team webhooks are queued rather than awaited, so no request waits on a third party and no third party outage fails a write that already committed. Closes #61 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 1 + apps/api-v2/.env.example | 9 +- apps/api-v2/package.json | 4 + apps/api-v2/roadmap.md | 20 +- apps/api-v2/src/app.module.ts | 2 + .../decorators/raw-response.decorator.ts | 13 + .../interceptors/response.interceptor.ts | 16 + apps/api-v2/src/common/queue/jobs.ts | 58 +++ apps/api-v2/src/common/queue/queue.module.ts | 14 + apps/api-v2/src/common/queue/queue.service.ts | 89 ++++ .../src/sections/claims/claims.controller.ts | 287 +++++++++- .../src/sections/claims/claims.service.ts | 491 +++++++++++++++++- .../src/sections/claims/dto/claim.dto.ts | 149 +++++- .../sections/claims/dto/create.claim.dto.ts | 136 +++++ .../sections/claims/dto/import.claim.dto.ts | 22 + .../sections/claims/dto/update.claim.dto.ts | 8 + apps/api-v2/src/sections/claims/util/area.ts | 98 ++++ .../interceptors/response.interceptor.spec.ts | 42 +- .../test/common/queue/queue.service.spec.ts | 83 +++ .../sections/claims/claims.controller.spec.ts | 223 ++++++-- .../sections/claims/claims.routes.spec.ts | 326 ++++++++++++ .../sections/claims/claims.service.spec.ts | 412 ++++++++++++++- .../test/sections/claims/util/area.spec.ts | 74 +++ yarn.lock | 234 ++++++++- 24 files changed, 2685 insertions(+), 126 deletions(-) create mode 100644 apps/api-v2/src/common/decorators/raw-response.decorator.ts create mode 100644 apps/api-v2/src/common/queue/jobs.ts create mode 100644 apps/api-v2/src/common/queue/queue.module.ts create mode 100644 apps/api-v2/src/common/queue/queue.service.ts create mode 100644 apps/api-v2/src/sections/claims/dto/create.claim.dto.ts create mode 100644 apps/api-v2/src/sections/claims/dto/import.claim.dto.ts create mode 100644 apps/api-v2/src/sections/claims/dto/update.claim.dto.ts create mode 100644 apps/api-v2/src/sections/claims/util/area.ts create mode 100644 apps/api-v2/test/common/queue/queue.service.spec.ts create mode 100644 apps/api-v2/test/sections/claims/claims.routes.spec.ts create mode 100644 apps/api-v2/test/sections/claims/util/area.spec.ts diff --git a/CLAUDE.md b/CLAUDE.md index 36d4e80b..0b0183d3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -67,6 +67,7 @@ Conventions that matter when adding an api-v2 endpoint: - **Auth is deny-by-default**: `AuthGuard` is registered as a global `APP_GUARD`. Opt out per route with `@SkipAuth()` (public) or `@OptionalAuth()` (token parsed if present, rejected if invalid). Authenticated requests carry `req.token` (a `BuildTeamProfileDto`, typed in `src/typings/express.d.ts`) — scope queries by `req.token.id`, which is the BuildTeam id. - **Auth model is per-BuildTeam, not per-user**: a team exchanges its stored `token` (client secret) for a JWT via `POST /auth`, signed with `JWT_SECRET`. There is no Keycloak in v2. - Modules must list `PrismaService` in their own `providers`; it is not a global module. +- **Slow or external work is queued, not awaited**: `QueueService` (`common/queue/`) adds BullMQ jobs to the `EventQueue` that `apps/worker` consumes, so reverse geocoding, Discord messages and build team webhooks never run inside a request. Job names and payload shapes live in `common/queue/jobs.ts` and mirror the Zod schemas in `apps/worker/src/tasks/` — a change to either has to be made on both. `QueueModule` is `@Global()`, unlike `PrismaService`, because it owns a Redis connection. Without `REDIS_URL` dispatching is a logged no-op, and a dispatch that fails is logged rather than thrown, so a queue outage never fails a write that already committed. - `src/main.ts` exports `bootstrap()` and only self-invokes under `require.main === module`, so tests can import it. - `apps/api-v2/roadmap.md` documents the intended URL/response/auth contract for v2 — consult it before designing a new endpoint. diff --git a/apps/api-v2/.env.example b/apps/api-v2/.env.example index 849fd841..55db2952 100644 --- a/apps/api-v2/.env.example +++ b/apps/api-v2/.env.example @@ -1 +1,8 @@ -JWT_SECRET=topsecret \ No newline at end of file +JWT_SECRET=topsecret + +# Redis the worker (apps/worker) reads its job queue from. Without it background +# jobs are dropped instead of queued, which is fine for local development. +REDIS_URL=redis://localhost:6379 + +# Used to build the links in the Discord messages the worker posts. +FRONTEND_URL=https://buildtheearth.net diff --git a/apps/api-v2/package.json b/apps/api-v2/package.json index 44796e6e..1d45f3af 100644 --- a/apps/api-v2/package.json +++ b/apps/api-v2/package.json @@ -26,10 +26,14 @@ "@nestjs/platform-express": "^11.0.1", "@nestjs/swagger": "^11.2.0", "@repo/db": "*", + "@turf/area": "^7.2.0", + "@turf/helpers": "^7.2.0", "axios": "^1.13.2", + "bullmq": "^5.77.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.2", "helmet": "^8.1.0", + "ioredis": "^5.10.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, diff --git a/apps/api-v2/roadmap.md b/apps/api-v2/roadmap.md index 45f8af49..da25a4a6 100644 --- a/apps/api-v2/roadmap.md +++ b/apps/api-v2/roadmap.md @@ -139,18 +139,18 @@ del /applications/templates/[tempId] ## Claims -get /claims \ -get /[teamId]/claims \ -get /claims/[claimId]?external={bool} \ -post /claims \ -post /claims/import \ -put /claims/[claimId]?external={bool} \ -del /claims/[claimId]?external={bool} \ -get /claims.geojson \ -get /[teamId]/claims.geojson \ +✅ get /claims \ +✅ get /[teamId]/claims \ +✅ get /claims/[claimId]?external={bool} \ +✅ post /claims \ +✅ post /claims/import \ +✅ put /claims/[claimId]?external={bool} \ +✅ del /claims/[claimId]?external={bool} \ +✅ get /claims.geojson \ +✅ get /[teamId]/claims.geojson \ ( \ -get /claims/images \ +✅ get /claims/images \ del /claims/[claimId]/images/[imgId] \ ) diff --git a/apps/api-v2/src/app.module.ts b/apps/api-v2/src/app.module.ts index 0129dec4..72f4282d 100644 --- a/apps/api-v2/src/app.module.ts +++ b/apps/api-v2/src/app.module.ts @@ -3,6 +3,7 @@ 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 { QueueModule } from './common/queue/queue.module'; 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'; @@ -23,6 +24,7 @@ import { UtilityModule } from './sections/utility/utility.module'; AuthModule, ClaimsModule, ConfigModule.forRoot({ isGlobal: true, cache: true }), + QueueModule, SocialsModule, StatusModule, UtilityModule, diff --git a/apps/api-v2/src/common/decorators/raw-response.decorator.ts b/apps/api-v2/src/common/decorators/raw-response.decorator.ts new file mode 100644 index 00000000..cfce492c --- /dev/null +++ b/apps/api-v2/src/common/decorators/raw-response.decorator.ts @@ -0,0 +1,13 @@ +import { SetMetadata } from '@nestjs/common'; + +export const IS_RAW_RESPONSE_KEY = 'isRawResponse'; + +/** + * Sends the handler's return value as-is, skipping the standard + * `{ status, message, data }` envelope. + * + * Only for routes whose body is a published file format rather than an API + * payload — the `.geojson` listings, which have to be loadable straight into a + * map client. Everything else keeps the envelope. + */ +export const RawResponse = () => SetMetadata(IS_RAW_RESPONSE_KEY, true); diff --git a/apps/api-v2/src/common/interceptors/response.interceptor.ts b/apps/api-v2/src/common/interceptors/response.interceptor.ts index 73fb9f7e..9158717f 100644 --- a/apps/api-v2/src/common/interceptors/response.interceptor.ts +++ b/apps/api-v2/src/common/interceptors/response.interceptor.ts @@ -1,16 +1,32 @@ import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; +import { IS_RAW_RESPONSE_KEY } from '../decorators/raw-response.decorator'; import { GenericControllerResponse, PaginatedMeta, Response } from 'src/typings'; /** * Interceptor that formats the response for all successful requests. * It wraps the response data in a standard format with status and message. * If the data contains pagination info, it adds the meta field automatically. + * + * Routes marked with @RawResponse are passed through untouched, for bodies that + * are a file format rather than an API payload. */ @Injectable() export class ResponseInterceptor implements NestInterceptor> { + private readonly reflector = new Reflector(); + intercept(context: ExecutionContext, next: CallHandler): Observable> { + const isRaw = this.reflector.getAllAndOverride(IS_RAW_RESPONSE_KEY, [ + context.getHandler(), + context.getClass(), + ]); + + if (isRaw) { + return next.handle() as Observable>; + } + return next.handle().pipe( map((data: GenericControllerResponse) => { const status: number = Number(context.switchToHttp().getResponse().statusCode); diff --git a/apps/api-v2/src/common/queue/jobs.ts b/apps/api-v2/src/common/queue/jobs.ts new file mode 100644 index 00000000..de561ede --- /dev/null +++ b/apps/api-v2/src/common/queue/jobs.ts @@ -0,0 +1,58 @@ +/** + * The contract between this API and `apps/worker`. + * + * The worker is a separate service that consumes a BullMQ queue, so the two are + * only coupled through the queue name, the job names and the payload shapes + * declared here. Every job below has a matching task in + * `apps/worker/src/tasks/`, and its payload mirrors that task's Zod schema — a + * change on either side has to be made on both. + */ +export const EVENT_QUEUE_NAME = 'EventQueue'; + +export enum WorkerJob { + /** Fills in a claim's center, building count and geocoded location. */ + SyncClaimOsm = 'SYNC_CLAIM_OSM', + /** Delivers an event to the webhook URLs of the given build teams. */ + BuildTeamWebhook = 'BUILDTEAM_WEBHOOK', + /** Posts a message to the staff-only Discord logging channel. */ + SendDiscordLog = 'SEND_DISCORD_LOG', + /** Sends a Discord DM to one or more users. */ + SendDiscordDm = 'SEND_DISCORD_DM', + /** Asks the frontend to revalidate cached pages. */ + RevalidateWebsite = 'REVALIDATE_WEBSITE', +} + +/** + * The event types the build team webhook task understands. + */ +export enum BuildTeamWebhookEvent { + Application = 'APPLICATION', + ApplicationSend = 'APPLICATION_SEND', + ClaimCreate = 'CLAIM_CREATE', + ClaimUpdate = 'CLAIM_UPDATE', + ClaimDelete = 'CLAIM_DELETE', +} + +/** + * A webhook destination. The worker resolves a team by ID or slug and reads its + * stored webhook URL, so this API never has to hold that URL itself. + */ +export type WebhookDestination = { id: string } | { slug: string } | { url: string }; + +export interface WorkerJobPayloads { + [WorkerJob.SyncClaimOsm]: { claimId: string }; + [WorkerJob.BuildTeamWebhook]: { + type: BuildTeamWebhookEvent; + data?: unknown; + destination: WebhookDestination[]; + }; + [WorkerJob.SendDiscordLog]: Record; + [WorkerJob.SendDiscordDm]: { + userId?: string; + userIds?: string[]; + discordId?: string; + discordIds?: string[]; + content: unknown; + }; + [WorkerJob.RevalidateWebsite]: { paths?: string[]; tags?: string[] }; +} diff --git a/apps/api-v2/src/common/queue/queue.module.ts b/apps/api-v2/src/common/queue/queue.module.ts new file mode 100644 index 00000000..b709d3c1 --- /dev/null +++ b/apps/api-v2/src/common/queue/queue.module.ts @@ -0,0 +1,14 @@ +import { Global, Module } from '@nestjs/common'; +import { QueueService } from './queue.service'; + +/** + * Global on purpose, unlike the other shared providers here: QueueService owns a + * Redis connection, and listing it in each module's own providers would open one + * connection per module. + */ +@Global() +@Module({ + providers: [QueueService], + exports: [QueueService], +}) +export class QueueModule {} diff --git a/apps/api-v2/src/common/queue/queue.service.ts b/apps/api-v2/src/common/queue/queue.service.ts new file mode 100644 index 00000000..54f60aa3 --- /dev/null +++ b/apps/api-v2/src/common/queue/queue.service.ts @@ -0,0 +1,89 @@ +import { Injectable, Logger, OnModuleDestroy } from '@nestjs/common'; +import { Queue } from 'bullmq'; +import Redis from 'ioredis'; +import { EVENT_QUEUE_NAME, WorkerJob, WorkerJobPayloads } from './jobs'; + +/** + * Matches the retry and removal behaviour `apps/worker` expects. The worker + * reads `job.opts.attempts` to decide whether a failure was the final one, so + * the producer is the side that has to set it. + */ +const DEFAULT_JOB_OPTIONS = { + attempts: 3, + backoff: { type: 'exponential' as const, delay: 1000 }, + removeOnComplete: { age: 3600, count: 200 }, + removeOnFail: { count: 200 }, +}; + +/** + * Hands work that does not belong in a request to `apps/worker`. + * + * Anything slow or externally dependent — reverse geocoding a claim, delivering + * a webhook, posting to Discord — is queued rather than awaited, so a request + * never waits on a third party and a third party being down never fails a write + * that already succeeded. + * + * The queue is optional on purpose: without REDIS_URL the service degrades to a + * no-op that logs, so local development and tests do not need a Redis. For the + * same reason a dispatch that fails is logged rather than thrown — the row is + * already committed by the time we get here, and answering 500 would tell the + * caller their write was lost when it was not. + */ +@Injectable() +export class QueueService implements OnModuleDestroy { + private readonly logger = new Logger(QueueService.name); + private readonly connection: Redis | null; + private readonly queue: Queue | null; + + constructor() { + const url = process.env.REDIS_URL; + + if (!url) { + this.logger.warn('REDIS_URL is not set. Background jobs will be dropped instead of queued.'); + this.connection = null; + this.queue = null; + return; + } + + this.connection = new Redis(url, { maxRetriesPerRequest: null, enableReadyCheck: false }); + this.queue = new Queue(EVENT_QUEUE_NAME, { + connection: this.connection, + defaultJobOptions: DEFAULT_JOB_OPTIONS, + }); + } + + /** + * Queues a job for the worker. + * @param name The job to run, which has to match a task in the worker's registry. + * @param payload The job payload, shaped like that task's schema. + * @returns Whether the job was queued. + */ + async dispatch(name: N, payload: WorkerJobPayloads[N]): Promise { + if (!this.queue) { + this.logger.debug(`Dropped ${name}: no queue configured`); + return false; + } + + try { + await this.queue.add(name, payload); + return true; + } catch (error) { + this.logger.error(`Failed to queue ${name}: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + } + + /** + * Queues several jobs at once, and reports how many of them made it. + */ + async dispatchAll(name: N, payloads: WorkerJobPayloads[N][]): Promise { + const results = await Promise.all(payloads.map((payload) => this.dispatch(name, payload))); + + return results.filter(Boolean).length; + } + + async onModuleDestroy() { + await this.queue?.close(); + this.connection?.disconnect(); + } +} diff --git a/apps/api-v2/src/sections/claims/claims.controller.ts b/apps/api-v2/src/sections/claims/claims.controller.ts index 99b58c9a..4d2a4a38 100644 --- a/apps/api-v2/src/sections/claims/claims.controller.ts +++ b/apps/api-v2/src/sections/claims/claims.controller.ts @@ -1,27 +1,130 @@ -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 { Body, Controller, Delete, Get, Param, ParseArrayPipe, Post, Put, Query, Req } from '@nestjs/common'; +import { ApiBearerAuth, ApiBody, ApiOperation, ApiParam, ApiQuery, 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 { 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 { Pagination, PaginationParams } from 'src/common/decorators/pagination.decorator'; +import { RawResponse } from 'src/common/decorators/raw-response.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 { TeamScope } from 'src/common/decorators/team-scope.decorator'; +import { ControllerResponse, PaginatedControllerResponse } from 'src/typings'; +import { ClaimsService, MAX_IMPORT_CLAIMS } from './claims.service'; +import { ClaimDto, ClaimImageDto } from './dto/claim.dto'; +import { CreateClaimDto } from './dto/create.claim.dto'; +import { ImportClaimDto } from './dto/import.claim.dto'; +import { UpdateClaimDto } from './dto/update.claim.dto'; -@Controller('claims') +/** + * Every route is registered twice: once bare, and once behind a `:teamId` + * prefix, so a caller that already carries the team id in its URLs can keep it + * there. The controller therefore has no prefix of its own, since a Nest + * controller prefix cannot be made optional. + * + * Reading is public, because the claims are what the public map draws. Writing + * is scoped to the authenticated team, so the prefix there has to name that same + * team. See TeamScope. + * + * Handler order matters: `claims/images` has to be declared before `claims/:id`, + * or the single-claim route would swallow it. + */ +@Controller() export class ClaimsController { constructor(private readonly claimsService: ClaimsService) {} - @Get() + /** + * Returns the claims as GeoJSON, for the public map. + */ + @Get(['claims.geojson', ':teamId/claims.geojson']) + @SkipAuth() + @RawResponse() + @ApiOperation({ + summary: 'Get Claims as GeoJSON', + description: + 'Returns the claims of the team in the path, or of every team when no team is given, as a GeoJSON FeatureCollection. Answers raw GeoJSON rather than the standard envelope, so the URL can be handed straight to a map client.', + }) + @ApiParam({ + name: 'teamId', + required: false, + description: 'The ID of the build team, or its slug when the slug query parameter is set.', + }) + @Filtered({ + fields: [ + { name: 'finished', required: false, type: Boolean }, + { name: 'active', required: false, type: Boolean }, + { name: 'props', required: false, type: Boolean }, + { name: 'slug', required: false, type: Boolean }, + ], + }) + @ApiResponse({ status: 200, description: 'A GeoJSON FeatureCollection of the matching claims.' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + async findAllGeoJson( + @Param('teamId') teamId: string | undefined, + @Filter() filter: FilterParams, + ): ControllerResponse { + const { props, slug, ...claimFilter }: { props?: boolean; slug?: boolean } = filter.filter; + + return await this.claimsService.findAllGeoJson( + { ...claimFilter, ...this.teamWhere(teamId, Boolean(slug)) }, + Boolean(props), + ); + } + + /** + * Lists the images attached to the authenticated team's claims. + */ + @Get(['claims/images', ':teamId/claims/images']) + @ApiBearerAuth() + @Paginated() + @ApiOperation({ + summary: 'Get Claim Images', + description: "Lists the images attached to the authenticated team's claims, newest first.", + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @Filtered({ fields: [{ name: 'checked', required: false, type: Boolean }] }) + @ApiPaginatedResponseDto(ClaimImageDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + async findAllImages( + @Pagination() pagination: PaginationParams, + @Filter() filter: FilterParams, + @TeamScope() buildTeamId: string, + ): PaginatedControllerResponse { + const { checked }: { checked?: boolean } = filter.filter; + + return await this.claimsService.findAllImages(pagination, buildTeamId, checked); + } + + /** + * Returns claims, either of the team named in the path, of the team named in the + * filter, or of the currently authenticated team. + */ + @Get(['claims', ':teamId/claims']) @OptionalAuth() @ApiBearerAuth() @Paginated() + @Sortable({ + defaultSortBy: 'createdAt', + allowedFields: ['name', 'city', 'createdAt', 'size', 'buildings', 'finished', 'active'], + defaultOrder: 'desc', + }) @ApiOperation({ - summary: 'Get All Claims', + summary: 'Get Claims', description: - 'Returns all claims for the given team. If no team is specified, returns claims for the authenticated team.', + 'Returns the claims of the team in the path or in the team filter. Falls back to the authenticated team when neither is given, and to every team when there is no token either.', + }) + @ApiParam({ + name: 'teamId', + required: false, + description: 'The ID of the build team, or its slug when the slug query parameter is set.', }) @Filtered({ fields: [ @@ -32,22 +135,152 @@ export class ClaimsController { ], }) @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 }; + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + async findAll( + @Param('teamId') teamId: string | undefined, + @Pagination() pagination: PaginationParams, + @Sorting() sorting: SortingParams, + @Filter() filter: FilterParams, + @Req() req: Request, + ): PaginatedControllerResponse { + const { team, slug, ...claimFilter }: { team?: string; slug?: boolean } = filter.filter; + + const teamWhere = (() => { + if (teamId) return this.teamWhere(teamId, Boolean(slug)); + if (team) return this.teamWhere(team, Boolean(slug)); + if (req.token) return { buildTeamId: req.token.id }; + return {}; })(); - return this.claimsService.findAll(pagination, { - ...otherFilters, - ...teamFilter, - }); + return await this.claimsService.findAll( + pagination, + { ...claimFilter, ...teamWhere }, + sorting.sortBy, + sorting.order, + ); + } + + /** + * Returns a single claim. + */ + @Get(['claims/:id', ':teamId/claims/:id']) + @SkipAuth() + @ApiOperation({ + summary: 'Get Claim', + description: + 'Returns the claim with the given ID. With external set, the ID is read as the one the claim carries in its own team system instead.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Ignored; the claim ID already identifies the team.' }) + @ApiQuery({ name: 'external', required: false, type: Boolean, description: 'Read the ID as an externalId.' }) + @ApiQuery({ name: 'builders', required: false, type: Boolean, description: 'Embed the builders of the claim.' }) + @ApiDefaultResponse(ClaimDto, { description: 'Success' }) + @ApiErrorResponse({ status: 404, description: 'Claim not found' }) + async findOne( + @Param('id') id: string, + @Query('external') external?: string, + @Query('builders') builders?: string, + ): ControllerResponse { + return await this.claimsService.findOne(id, external === 'true', builders === 'true'); + } + + /** + * Creates a claim for the currently authenticated team. + */ + @Post(['claims', ':teamId/claims']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Create Claim', + description: + 'Creates a claim for the currently authenticated team. The building count and the geocoded location are filled in afterwards by the worker, so they are absent from the response.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiDefaultResponse(ClaimDto, { status: 201, description: 'Claim created successfully.' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'User not found' }) + async create(@Body() createClaimDto: CreateClaimDto, @TeamScope() buildTeamId: string): ControllerResponse { + return await this.claimsService.create(createClaimDto, buildTeamId); + } + + /** + * Creates and updates claims of the currently authenticated team in bulk. + */ + @Post(['claims/import', ':teamId/claims/import']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Import Claims', + description: `Creates and updates claims of the currently authenticated team in one request, matched on externalId. Claims of the team that are not part of the payload are left untouched. At most ${MAX_IMPORT_CLAIMS} claims can be sent at once.`, + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiBody({ type: [ImportClaimDto] }) + @ApiResponse({ status: 201, description: 'The imported claims, with how many were created and how many updated.' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Claim not found' }) + async importClaims( + @Body(new ParseArrayPipe({ items: ImportClaimDto, whitelist: true, forbidNonWhitelisted: true })) + importClaimDtos: ImportClaimDto[], + @TeamScope() buildTeamId: string, + ): ControllerResponse { + return await this.claimsService.importMany(importClaimDtos, buildTeamId); + } + + /** + * Updates a claim of the currently authenticated team. + */ + @Put(['claims/:id', ':teamId/claims/:id']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Update Claim', + description: + 'Updates the claim with the given ID if it belongs to the currently authenticated team. With external set, the ID is read as the one the claim carries in the team system instead.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiQuery({ name: 'external', required: false, type: Boolean, description: 'Read the ID as an externalId.' }) + @ApiDefaultResponse(ClaimDto, { description: 'Success' }) + @ApiErrorResponse({ status: 400, description: 'Bad Request' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Claim not found' }) + async update( + @Param('id') id: string, + @Body() updateClaimDto: UpdateClaimDto, + @TeamScope() buildTeamId: string, + @Query('external') external?: string, + ): ControllerResponse { + return await this.claimsService.update(id, external === 'true', updateClaimDto, buildTeamId); + } + + /** + * Deletes a claim of the currently authenticated team. + */ + @Delete(['claims/:id', ':teamId/claims/:id']) + @ApiBearerAuth() + @ApiOperation({ + summary: 'Delete Claim', + description: + 'Deletes the claim with the given ID if it belongs to the currently authenticated team. With external set, the ID is read as the one the claim carries in the team system instead.', + }) + @ApiParam({ name: 'teamId', required: false, description: 'Must be the authenticated team when given.' }) + @ApiQuery({ name: 'external', required: false, type: Boolean, description: 'Read the ID as an externalId.' }) + @ApiDefaultResponse(ClaimDto, { description: 'Claim deleted successfully.' }) + @ApiErrorResponse({ status: 401, description: 'Unauthorized' }) + @ApiErrorResponse({ status: 404, description: 'Claim not found' }) + async delete( + @Param('id') id: string, + @TeamScope() buildTeamId: string, + @Query('external') external?: string, + ): ControllerResponse { + return await this.claimsService.delete(id, external === 'true', buildTeamId); + } + + /** + * Resolves a team named in a path segment or filter into a claim where clause. + */ + private teamWhere(team: string | undefined, useSlug: boolean) { + if (!team) { + return {}; + } + + return useSlug ? { buildTeam: { slug: team } } : { buildTeamId: team }; } } diff --git a/apps/api-v2/src/sections/claims/claims.service.ts b/apps/api-v2/src/sections/claims/claims.service.ts index 93042f8b..67adfb73 100644 --- a/apps/api-v2/src/sections/claims/claims.service.ts +++ b/apps/api-v2/src/sections/claims/claims.service.ts @@ -1,13 +1,70 @@ -import { Injectable } from '@nestjs/common'; -import { FilterParams } from 'src/common/decorators/filter.decorator'; +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@repo/db'; 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 { BuildTeamWebhookEvent, WorkerJob } from 'src/common/queue/jobs'; +import { QueueService } from 'src/common/queue/queue.service'; +import { ClaimUserRefDto, CreateClaimDto } from './dto/create.claim.dto'; +import { ImportClaimDto } from './dto/import.claim.dto'; +import { UpdateClaimDto } from './dto/update.claim.dto'; +import { areaCenter, areaSize, toGeoJsonRing } from './util/area'; + +/** Upper bound for a single bulk import, so one request cannot hold the table. */ +export const MAX_IMPORT_CLAIMS = 100; + +/** The user columns a claim embeds for its owner and builders. */ +const USER_SELECT = { + id: true, + ssoId: true, + discordId: true, + minecraft: true, + username: true, + avatar: true, +} as const; + +/** The image columns a claim embeds. */ +const IMAGE_SELECT = { + id: true, + name: true, + hash: true, + width: true, + height: true, + createdAt: true, +} as const; + +/** The build team columns a claim embeds, so a listing can be labelled in one request. */ +const BUILD_TEAM_SELECT = { + id: true, + name: true, + location: true, + slug: true, + icon: true, + allowBuilderClaim: true, +} as const; @Injectable() export class ClaimsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly queue: QueueService, + ) {} - async findAll(pagination: PaginationParams, filter: FilterParams['filter']) { + /** + * Finds claims based on pagination, sorting and filtering parameters. + * @param pagination Pagination parameters. + * @param filter Filter parameters, already resolved to a Prisma where clause. + * @param sortBy Field to sort by. + * @param order Order of sorting (asc/desc). + * @returns A paginated response containing the claims and metadata. + */ + async findAll( + pagination: PaginationParams, + filter: FilterParams['filter'], + sortBy?: SortingParams['sortBy'], + order?: SortingParams['order'], + ) { const limit = Math.max(Number(pagination.limit) || 20, 1); const page = Math.max(Number(pagination.page) || 1, 1); const skip = (page - 1) * limit; @@ -15,6 +72,7 @@ export class ClaimsService { const [claims, total] = await Promise.all([ this.prisma.claim.findMany({ where: filter, + orderBy: { [sortBy || 'createdAt']: order === 'asc' ? 'asc' : 'desc' }, skip, take: limit, include: { @@ -35,4 +93,429 @@ export class ClaimsService { }, }; } + + /** + * Finds a single claim by its ID, or by the ID it carries in its team's own + * system. + * @param id The claim ID, or its externalId when external is set. + * @param external Whether id is an externalId rather than a claim ID. + * @param withBuilders Whether to embed the builders of the claim. + * @returns The claim. + * @throws NotFoundException if no such claim exists. + */ + async findOne(id: string, external: boolean, withBuilders: boolean) { + const claim = await this.prisma.claim.findFirst({ + where: external ? { externalId: id } : { id }, + include: { + owner: { select: USER_SELECT }, + buildTeam: { select: BUILD_TEAM_SELECT }, + images: { select: IMAGE_SELECT }, + builders: withBuilders ? { select: USER_SELECT, take: 20 } : false, + _count: { select: { builders: true, images: true } }, + }, + }); + + if (!claim) { + throw new NotFoundException('Claim not found'); + } + + return claim; + } + + /** + * Returns the claims as a GeoJSON FeatureCollection, which is what the public + * map loads. + * + * Unpaginated on purpose: a partial map is worse than a slow one, and the + * default projection is three columns wide. + * @param filter Filter parameters, already resolved to a Prisma where clause. + * @param withProperties Whether to include every claim column as feature properties. + * @returns A GeoJSON FeatureCollection. + */ + async findAllGeoJson(filter: FilterParams['filter'], withProperties: boolean) { + const claims = await this.prisma.claim.findMany({ + where: filter, + select: withProperties + ? { + id: true, + area: true, + finished: true, + active: true, + name: true, + city: true, + osmName: true, + buildings: true, + size: true, + createdAt: true, + owner: { select: USER_SELECT }, + builders: { select: USER_SELECT }, + buildTeam: { select: { id: true, slug: true, name: true, location: true } }, + images: { select: IMAGE_SELECT }, + } + : { id: true, area: true, finished: true }, + }); + + return { + type: 'FeatureCollection', + features: claims + .filter((claim) => claim.area.length > 0) + .map(({ area, ...properties }) => ({ + type: 'Feature', + id: properties.id, + geometry: { type: 'Polygon', coordinates: [toGeoJsonRing(area)] }, + properties, + })), + }; + } + + /** + * Lists the images attached to the given team's claims, newest first, so a team + * can review what was uploaded against its claims. + * @param pagination Pagination parameters. + * @param buildTeamId ID of the team whose claim images to list. + * @param checked Whether to restrict the result to reviewed or unreviewed images. + * @returns A paginated response containing the images and metadata. + */ + async findAllImages(pagination: PaginationParams, buildTeamId: string, checked?: boolean) { + const where = { + Claim: { buildTeamId }, + ...(checked === undefined ? {} : { checked }), + }; + + const take = Math.max(Number(pagination.limit) || 20, 1); + const skip = Math.max((Number(pagination.page) || 1) - 1, 0) * take; + + const [images, count] = await Promise.all([ + this.prisma.upload.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take, + include: { Claim: { select: { id: true, name: true, buildTeamId: true } } }, + }), + this.prisma.upload.count({ where }), + ]); + + return { + data: images, + meta: { + page: pagination.page, + perPage: pagination.limit, + totalItems: count, + totalPages: Math.ceil(count / pagination.limit), + }, + }; + } + + /** + * Creates a claim for the given team. + * + * Only the geometry that can be worked out locally is written here. The + * building count and the geocoded location come from OpenStreetMap, so they are + * left to the worker rather than made part of the request. + * @param dto The claim to create. + * @param buildTeamId ID of the team the claim belongs to. + * @returns The created claim. + * @throws NotFoundException if the named owner or builders do not exist. + */ + async create(dto: CreateClaimDto, buildTeamId: string) { + const [ownerId, builderIds] = await Promise.all([this.resolveUser(dto.owner), this.resolveUsers(dto.builders)]); + + const claim = await this.prisma.claim.create({ + data: { + ...this.geometryOf(dto.area), + name: dto.name ?? '', + description: dto.description, + finished: dto.finished, + active: dto.active, + externalId: dto.externalId, + buildings: dto.buildings, + city: dto.city, + buildTeam: { connect: { id: buildTeamId } }, + ...(ownerId ? { owner: { connect: { id: ownerId } } } : {}), + ...(builderIds ? { builders: { connect: builderIds.map((id) => ({ id })) } } : {}), + }, + }); + + await this.announce(BuildTeamWebhookEvent.ClaimCreate, claim, buildTeamId); + + return claim; + } + + /** + * Creates and updates claims in bulk, matching on the ID each claim carries in + * the team's own system. Claims of the team that are not part of the payload are + * left untouched. + * @param dtos The claims to import. + * @param buildTeamId ID of the team the claims belong to. + * @returns The imported claims, and how many of them were new. + * @throws BadRequestException if more than MAX_IMPORT_CLAIMS claims are sent at once, + * or if the payload names the same externalId twice. + * @throws NotFoundException if an externalId already belongs to another team. + */ + async importMany(dtos: ImportClaimDto[], buildTeamId: string) { + if (dtos.length > MAX_IMPORT_CLAIMS) { + throw new BadRequestException(`Cannot import more than ${MAX_IMPORT_CLAIMS} claims at once`); + } + + const externalIds = dtos.map((dto) => dto.externalId); + + if (new Set(externalIds).size !== externalIds.length) { + throw new BadRequestException('The same externalId was given more than once'); + } + + const existing = await this.prisma.claim.findMany({ + where: { externalId: { in: externalIds } }, + select: { id: true, externalId: true, buildTeamId: true }, + }); + + // A 404 rather than a 403: saying "that one is someone else's" would confirm + // the ID exists, which is what every other route here avoids doing. + if (existing.some((claim) => claim.buildTeamId !== buildTeamId)) { + throw new NotFoundException('Claim not found'); + } + + const existingByExternalId = new Map(existing.map((claim) => [claim.externalId, claim.id])); + + const resolved = await Promise.all( + dtos.map(async (dto) => ({ + dto, + ownerId: await this.resolveUser(dto.owner), + builderIds: await this.resolveUsers(dto.builders), + })), + ); + + const claims = await this.prisma.$transaction( + resolved.map(({ dto, ownerId, builderIds }) => { + const data = { + ...this.geometryOf(dto.area), + name: dto.name ?? '', + description: dto.description, + finished: dto.finished, + active: dto.active, + buildings: dto.buildings, + city: dto.city, + ...(ownerId ? { owner: { connect: { id: ownerId } } } : {}), + }; + + const id = existingByExternalId.get(dto.externalId); + + if (id) { + return this.prisma.claim.update({ + where: { id }, + data: { + ...data, + ...(builderIds ? { builders: { set: builderIds.map((builderId) => ({ id: builderId })) } } : {}), + }, + }); + } + + return this.prisma.claim.create({ + data: { + ...data, + externalId: dto.externalId, + buildTeam: { connect: { id: buildTeamId } }, + ...(builderIds ? { builders: { connect: builderIds.map((builderId) => ({ id: builderId })) } } : {}), + }, + }); + }), + ); + + await Promise.all( + claims.map((claim) => + this.announce( + existingByExternalId.has(claim.externalId) + ? BuildTeamWebhookEvent.ClaimUpdate + : BuildTeamWebhookEvent.ClaimCreate, + claim, + buildTeamId, + ), + ), + ); + + return { + claims, + created: claims.filter((claim) => !existingByExternalId.has(claim.externalId)).length, + updated: claims.filter((claim) => existingByExternalId.has(claim.externalId)).length, + }; + } + + /** + * Updates a claim if it belongs to the given team. + * @param id The claim ID, or its externalId when external is set. + * @param external Whether id is an externalId rather than a claim ID. + * @param dto The fields to update. + * @param buildTeamId ID of the team the claim has to belong to. + * @returns The updated claim. + * @throws NotFoundException if the claim does not exist or belongs to another team. + */ + async update(id: string, external: boolean, dto: UpdateClaimDto, buildTeamId: string) { + const claim = await this.prisma.claim.findFirst({ + where: external ? { externalId: id, buildTeamId } : { id, buildTeamId }, + select: { id: true }, + }); + + if (!claim) { + throw new NotFoundException('Claim not found'); + } + + const [ownerId, builderIds] = await Promise.all([this.resolveUser(dto.owner), this.resolveUsers(dto.builders)]); + + const updated = await this.prisma.claim.update({ + where: { id: claim.id }, + data: { + ...(dto.area ? this.geometryOf(dto.area) : {}), + name: dto.name, + description: dto.description, + finished: dto.finished, + active: dto.active, + externalId: dto.externalId, + buildings: dto.buildings, + city: dto.city, + ...(ownerId ? { owner: { connect: { id: ownerId } } } : {}), + ...(builderIds ? { builders: { set: builderIds.map((builderId) => ({ id: builderId })) } } : {}), + }, + }); + + // Only worth re-deriving when the outline moved; the counts and the geocoded + // name are properties of the area, not of the rest of the claim. + await this.announce(BuildTeamWebhookEvent.ClaimUpdate, updated, buildTeamId, Boolean(dto.area)); + + return updated; + } + + /** + * Deletes a claim if it belongs to the given team. + * @param id The claim ID, or its externalId when external is set. + * @param external Whether id is an externalId rather than a claim ID. + * @param buildTeamId ID of the team the claim has to belong to. + * @returns The deleted claim. + * @throws NotFoundException if the claim does not exist or belongs to another team. + */ + async delete(id: string, external: boolean, buildTeamId: string) { + const claim = await this.prisma.claim.findFirst({ + where: external ? { externalId: id, buildTeamId } : { id, buildTeamId }, + }); + + if (!claim) { + throw new NotFoundException('Claim not found'); + } + + await this.prisma.claim.delete({ where: { id: claim.id } }); + + await this.announce(BuildTeamWebhookEvent.ClaimDelete, claim, buildTeamId, false); + + return claim; + } + + /** + * The geometry that can be derived without leaving the process. The building + * count and the geocoded location need OpenStreetMap, so the worker fills those + * in afterwards. + */ + private geometryOf(area: string[]) { + return { + area, + size: areaSize(area), + center: areaCenter(area), + }; + } + + /** + * Tells the outside world that a claim changed, without making the request wait + * for any of it: the team's webhook, the staff Discord log, and — when the + * outline changed — the OpenStreetMap sync that fills in the derived columns. + */ + private async announce( + event: BuildTeamWebhookEvent, + claim: { id: string; name: string; finished: boolean; active: boolean; createdAt: Date }, + buildTeamId: string, + syncOsm = true, + ) { + await Promise.all([ + this.queue.dispatch(WorkerJob.BuildTeamWebhook, { + type: event, + data: claim, + destination: [{ id: buildTeamId }], + }), + this.queue.dispatch(WorkerJob.SendDiscordLog, this.discordLogFor(event, claim)), + ...(syncOsm ? [this.queue.dispatch(WorkerJob.SyncClaimOsm, { claimId: claim.id })] : []), + ]); + } + + /** + * The Discord embed the staff log channel gets for a claim event, shaped like + * the one v1 posted. + */ + private discordLogFor( + event: BuildTeamWebhookEvent, + claim: { id: string; name: string; finished: boolean; active: boolean; createdAt: Date }, + ): Record { + const titles: Partial> = { + [BuildTeamWebhookEvent.ClaimCreate]: 'Claim created', + [BuildTeamWebhookEvent.ClaimUpdate]: 'Claim updated', + [BuildTeamWebhookEvent.ClaimDelete]: 'Claim deleted', + }; + + return { + username: 'Claims', + embeds: [ + { + title: claim.name || claim.id, + url: `${process.env.FRONTEND_URL ?? ''}/map?claim=${claim.id}`, + author: { name: titles[event] ?? 'Claim changed' }, + fields: [ + { name: 'Finished', value: claim.finished ? '✅' : '❌', inline: true }, + { name: 'Active', value: claim.active ? '✅' : '❌', inline: true }, + ], + timestamp: claim.createdAt, + }, + ], + }; + } + + /** + * Resolves the user a caller named by ID, Keycloak ID, Discord ID or Minecraft + * name. + * @returns The user's ID, or undefined when no user was named. + * @throws BadRequestException if the reference names no field at all. + * @throws NotFoundException if no matching user exists. + */ + private async resolveUser(ref?: ClaimUserRefDto): Promise { + if (!ref) { + return undefined; + } + + const where: Prisma.UserWhereInput = {}; + + if (ref.id) where.id = ref.id; + if (ref.ssoId) where.ssoId = ref.ssoId; + if (ref.discordId) where.discordId = ref.discordId; + if (ref.minecraft) where.minecraft = ref.minecraft; + + if (Object.keys(where).length === 0) { + throw new BadRequestException('A user has to be named by id, ssoId, discordId or minecraft'); + } + + const user = await this.prisma.user.findFirst({ where, select: { id: true } }); + + if (!user) { + throw new NotFoundException('User not found'); + } + + return user.id; + } + + /** + * Resolves a list of user references. + * @returns The users' IDs, or undefined when no list was given, so that callers + * can tell "leave the builders alone" apart from "set them to nothing". + */ + private async resolveUsers(refs?: ClaimUserRefDto[]): Promise { + if (!refs) { + return undefined; + } + + return await Promise.all(refs.map(async (ref) => (await this.resolveUser(ref)) as string)); + } } 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 3fdcaff6..d27e9c03 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,159 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class ClaimUserDto { + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The unique ID of the user.' }) + id: string; + + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The Keycloak ID of the user.' }) + ssoId: string; + + @ApiPropertyOptional({ example: '123456789012345678', description: 'The Discord ID of the user.' }) + discordId?: string | null; + + @ApiPropertyOptional({ example: 'Notch', description: 'The Minecraft name of the user.' }) + minecraft?: string | null; + + @ApiPropertyOptional({ example: 'notch', description: 'The username of the user.' }) + username?: string | null; + + @ApiPropertyOptional({ example: 'https://example.com/avatar.png', description: 'The avatar of the user.' }) + avatar?: string | null; +} + +export class ClaimImageDto { + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The unique ID of the upload.' }) + id: string; + + @ApiProperty({ example: 'd1f4c0b8f9a24d4f', description: 'The key the image is stored under in the CDN bucket.' }) + name: string; + + @ApiProperty({ + example: 'data:image/png;base64,iVBORw0KGgo=', + description: 'A blurred placeholder rendered while the full image loads.', + }) + hash: string; + + @ApiPropertyOptional({ example: 1920, description: 'The width of the image in pixels.' }) + width?: number; + + @ApiPropertyOptional({ example: 1080, description: 'The height of the image in pixels.' }) + height?: number; + + @ApiPropertyOptional({ example: false, description: 'Whether the image has been reviewed by a moderator.' }) + checked?: boolean; + + @ApiPropertyOptional({ example: '2025-04-19T16:45:18.767Z', description: 'When the image was uploaded.' }) + createdAt?: string; +} + +export class ClaimBuildTeamDto { + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The unique ID of the build team.' }) + id: string; + + @ApiProperty({ example: 'Build Team Name', description: 'The name of the build team.' }) + name: string; + + @ApiProperty({ example: 'Country', description: 'The location of the build team.' }) + location: string; + + @ApiProperty({ example: 'build-team-slug', description: 'The slug of the build team.' }) + slug: string; + + @ApiProperty({ example: 'https://example.com/icon.png', description: 'The icon of the build team.' }) + icon: string; + + @ApiPropertyOptional({ example: true, description: 'Whether the team lets its builders create claims.' }) + allowBuilderClaim?: boolean | null; +} + +export class ClaimCountDto { + @ApiProperty({ example: 3, description: 'The number of builders on this claim.' }) + builders: number; + + @ApiProperty({ example: 2, description: 'The number of images attached to this claim.' }) + images: number; +} + export class ClaimDto { + @ApiProperty({ example: '00000000-0000-0000-0000-000000000000', description: 'The unique ID of the claim.' }) id: string; + + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the user who owns the claim.', + }) ownerId: string | null; + + @ApiProperty({ + type: [String], + example: ['-73.9857, 40.7484', '-73.9847, 40.7484', '-73.9847, 40.7474', '-73.9857, 40.7484'], + description: 'The outline of the claim as "lng, lat" points.', + }) area: string[]; + + @ApiPropertyOptional({ + example: '-73.9852, 40.7479', + description: 'The centre of the claim\u2019s bounding box, as a "lng, lat" point.', + }) center: string | null; + + @ApiProperty({ example: 12045, description: 'The area of the claim in square metres.' }) size: number; + + @ApiProperty({ example: true, description: 'Whether the claim is currently being built on.' }) active: boolean; + + @ApiProperty({ example: false, description: 'Whether the build is finished.' }) finished: boolean; + + @ApiProperty({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The ID of the build team this claim belongs to.', + }) buildTeamId: string; + + @ApiProperty({ example: 'Empire State Building', description: 'The name of the claim.' }) name: string; + + @ApiProperty({ example: '2025-04-19T16:45:18.767Z', description: 'When the claim was created.' }) createdAt: string; + + @ApiPropertyOptional({ + example: 'team-internal-42', + description: "The ID this claim has in its team's own system.", + }) externalId: string | null; + + @ApiPropertyOptional({ description: 'A longer description of what was built.' }) description: string | null; + + @ApiProperty({ + example: 12, + description: 'The number of buildings in the claim, counted from OpenStreetMap by the worker.', + }) buildings: number; + + @ApiPropertyOptional({ example: 'New York', description: 'The city the claim is in.' }) city: string | null; + + @ApiPropertyOptional({ + example: 'Empire State Building, 350, 5th Avenue, Manhattan, New York', + description: 'The full OpenStreetMap name of the claim location.', + }) osmName: string | null; - _count: { - builders: number; - images: number; - }; + @ApiPropertyOptional({ type: ClaimCountDto, description: 'How many builders and images this claim has.' }) + _count?: ClaimCountDto; + + @ApiPropertyOptional({ type: [ClaimImageDto], description: 'The images attached to this claim.' }) + images?: ClaimImageDto[]; + + @ApiPropertyOptional({ type: ClaimUserDto, description: 'The user who owns the claim.' }) + owner?: ClaimUserDto | null; + + @ApiPropertyOptional({ type: [ClaimUserDto], description: 'The users building on the claim.' }) + builders?: ClaimUserDto[]; - images: { - id: string; - name: string; - hash: string; - }[]; + @ApiPropertyOptional({ type: ClaimBuildTeamDto, description: 'The build team this claim belongs to.' }) + buildTeam?: ClaimBuildTeamDto; } diff --git a/apps/api-v2/src/sections/claims/dto/create.claim.dto.ts b/apps/api-v2/src/sections/claims/dto/create.claim.dto.ts new file mode 100644 index 00000000..6e38b584 --- /dev/null +++ b/apps/api-v2/src/sections/claims/dto/create.claim.dto.ts @@ -0,0 +1,136 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + ArrayMaxSize, + ArrayMinSize, + IsArray, + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + ValidateNested, +} from 'class-validator'; + +/** Upper bound on the builders that can be attached to a claim in one request. */ +export const MAX_BUILDERS = 20; + +/** + * How a claim's owner or builder is named. + * + * v1 accepted a free-form object and passed it straight to Prisma as a `where`, + * which let a caller query the user table on any column. The fields are listed + * explicitly here instead; exactly one has to be given. + */ +export class ClaimUserRefDto { + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The BuildTheEarth user ID.', + }) + @IsUUID() + @IsOptional() + id?: string; + + @ApiPropertyOptional({ + example: '00000000-0000-0000-0000-000000000000', + description: 'The Keycloak ID of the user.', + }) + @IsString() + @IsNotEmpty() + @IsOptional() + ssoId?: string; + + @ApiPropertyOptional({ example: '123456789012345678', description: 'The Discord ID of the user.' }) + @IsString() + @IsNotEmpty() + @IsOptional() + discordId?: string; + + @ApiPropertyOptional({ example: 'Notch', description: 'The Minecraft name of the user.' }) + @IsString() + @IsNotEmpty() + @IsOptional() + minecraft?: string; +} + +export class CreateClaimDto { + @ApiProperty({ + type: [String], + example: ['-73.9857, 40.7484', '-73.9847, 40.7484', '-73.9847, 40.7474', '-73.9857, 40.7474'], + description: + 'The outline of the claim as "lng, lat" points. The ring is closed automatically when the last point does not repeat the first.', + }) + @IsArray() + @ArrayMinSize(3) + @IsString({ each: true }) + area: string[]; + + @ApiPropertyOptional({ example: 'Empire State Building', description: 'The name of the claim.' }) + @IsString() + @MaxLength(255) + @IsOptional() + name?: string; + + @ApiPropertyOptional({ description: 'A longer description of what was built.' }) + @IsString() + @IsOptional() + description?: string; + + @ApiPropertyOptional({ example: false, description: 'Whether the build is finished.' }) + @IsBoolean() + @IsOptional() + finished?: boolean; + + @ApiPropertyOptional({ example: true, description: 'Whether the claim is currently being built on.' }) + @IsBoolean() + @IsOptional() + active?: boolean; + + @ApiPropertyOptional({ + example: 'team-internal-42', + description: "The ID this claim has in the team's own system. Must be unique across all claims.", + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + @IsOptional() + externalId?: string; + + @ApiPropertyOptional({ + example: 12, + description: 'The number of buildings in the claim. Counted from OpenStreetMap in the background when omitted.', + }) + @IsInt() + @Min(0) + @IsOptional() + buildings?: number; + + @ApiPropertyOptional({ + example: 'New York', + description: 'The city the claim is in. Reverse geocoded in the background when omitted.', + }) + @IsString() + @MaxLength(255) + @IsOptional() + city?: string; + + @ApiPropertyOptional({ type: ClaimUserRefDto, description: 'The user who owns the claim.' }) + @ValidateNested() + @Type(() => ClaimUserRefDto) + @IsOptional() + owner?: ClaimUserRefDto; + + @ApiPropertyOptional({ + type: [ClaimUserRefDto], + description: `The users building on the claim. At most ${MAX_BUILDERS}.`, + }) + @IsArray() + @ArrayMaxSize(MAX_BUILDERS) + @ValidateNested({ each: true }) + @Type(() => ClaimUserRefDto) + @IsOptional() + builders?: ClaimUserRefDto[]; +} diff --git a/apps/api-v2/src/sections/claims/dto/import.claim.dto.ts b/apps/api-v2/src/sections/claims/dto/import.claim.dto.ts new file mode 100644 index 00000000..0db455e2 --- /dev/null +++ b/apps/api-v2/src/sections/claims/dto/import.claim.dto.ts @@ -0,0 +1,22 @@ +import { ApiProperty, OmitType } from '@nestjs/swagger'; +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; +import { CreateClaimDto } from './create.claim.dto'; + +/** + * A single entry of a bulk import. + * + * Unlike a plain create, `externalId` is required: an import matches the claims + * it is given against the ones the team already has, and that ID is what it + * matches on. It is omitted from the base and redeclared rather than narrowed in + * place, since a subclass cannot tighten an optional property. + */ +export class ImportClaimDto extends OmitType(CreateClaimDto, ['externalId'] as const) { + @ApiProperty({ + example: 'team-internal-42', + description: "The ID this claim has in the team's own system. Existing claims with this ID are updated.", + }) + @IsString() + @IsNotEmpty() + @MaxLength(255) + externalId: string; +} diff --git a/apps/api-v2/src/sections/claims/dto/update.claim.dto.ts b/apps/api-v2/src/sections/claims/dto/update.claim.dto.ts new file mode 100644 index 00000000..fedf6862 --- /dev/null +++ b/apps/api-v2/src/sections/claims/dto/update.claim.dto.ts @@ -0,0 +1,8 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateClaimDto } from './create.claim.dto'; + +/** + * Every field of a claim can be updated on its own, so all fields of the create + * DTO are optional here while keeping their validation rules. + */ +export class UpdateClaimDto extends PartialType(CreateClaimDto) {} diff --git a/apps/api-v2/src/sections/claims/util/area.ts b/apps/api-v2/src/sections/claims/util/area.ts new file mode 100644 index 00000000..a9c090f8 --- /dev/null +++ b/apps/api-v2/src/sections/claims/util/area.ts @@ -0,0 +1,98 @@ +import { BadRequestException } from '@nestjs/common'; +import turfArea from '@turf/area'; +import { polygon } from '@turf/helpers'; +import { Polygon } from 'geojson'; + +/** + * A claim's area is stored as a list of `"lng, lat"` strings, the same shape v1 + * writes and `apps/worker` reads. These helpers are the only place that format + * is interpreted. + */ + +/** Smallest number of points that can describe a polygon, before closing it. */ +const MIN_RING_POINTS = 3; + +/** + * Parses the stored `"lng, lat"` strings into numeric positions. + * @throws BadRequestException if a point is not two finite numbers. + */ +export function parseArea(area: string[]): [number, number][] { + return area.map((point) => { + const parts = point.split(',').map((part) => Number(part.trim())); + + if (parts.length !== 2 || parts.some((value) => !Number.isFinite(value))) { + throw new BadRequestException(`Invalid coordinate: ${point}. Expected "lng, lat".`); + } + + const [lng, lat] = parts; + + if (lng < -180 || lng > 180 || lat < -90 || lat > 90) { + throw new BadRequestException(`Coordinate out of range: ${point}. Expected "lng, lat".`); + } + + return [lng, lat]; + }); +} + +/** + * Repeats the first point at the end when the caller did not close the ring, so + * the stored area is always a valid polygon. + */ +export function closeRing(area: string[]): string[] { + if (area.length === 0 || area[0] === area[area.length - 1]) { + return area; + } + + return [...area, area[0]]; +} + +/** + * Builds a GeoJSON polygon from a stored area. + * @throws BadRequestException if the area cannot describe a polygon. + */ +export function toPolygon(area: string[]): Polygon { + const ring = parseArea(closeRing(area)); + + if (ring.length < MIN_RING_POINTS + 1) { + throw new BadRequestException(`An area needs at least ${MIN_RING_POINTS} points.`); + } + + return polygon([ring]).geometry; +} + +/** + * The centre of the area's bounding box, as a `"lng, lat"` string. + * + * Deliberately the bounding box centre rather than the centroid: it is what v1 + * stored and what the worker's OSM sync recomputes, so all three agree. + */ +export function areaCenter(area: string[]): string { + const points = parseArea(area); + + if (points.length === 0) { + throw new BadRequestException('An area needs at least one point.'); + } + + const lngs = points.map(([lng]) => lng); + const lats = points.map(([, lat]) => lat); + + const centerLng = (Math.min(...lngs) + Math.max(...lngs)) / 2; + const centerLat = (Math.min(...lats) + Math.max(...lats)) / 2; + + return `${centerLng}, ${centerLat}`; +} + +/** + * The area of the claim in square metres, rounded to an integer because the + * column is one. + */ +export function areaSize(area: string[]): number { + return Math.round(turfArea(toPolygon(area))); +} + +/** + * The polygon ring a GeoJSON feature needs, closed. + */ +export function toGeoJsonRing(area: string[]): [number, number][] { + return parseArea(closeRing(area)); +} diff --git a/apps/api-v2/test/common/interceptors/response.interceptor.spec.ts b/apps/api-v2/test/common/interceptors/response.interceptor.spec.ts index 41c03ee1..fdeb3821 100644 --- a/apps/api-v2/test/common/interceptors/response.interceptor.spec.ts +++ b/apps/api-v2/test/common/interceptors/response.interceptor.spec.ts @@ -1,15 +1,31 @@ import { of } from 'rxjs'; +import { IS_RAW_RESPONSE_KEY } from 'src/common/decorators/raw-response.decorator'; import { ResponseInterceptor } from 'src/common/interceptors/response.interceptor'; +/** + * Builds an ExecutionContext double. The interceptor reads metadata off the + * handler to decide whether to wrap, so the handler is where `raw` is recorded. + */ +const contextFor = (statusCode: number, raw = false) => { + const handler = () => undefined; + + if (raw) { + Reflect.defineMetadata(IS_RAW_RESPONSE_KEY, true, handler); + } + + return { + switchToHttp: () => ({ getResponse: () => ({ statusCode }) }), + getHandler: () => handler, + getClass: () => class {}, + } as any; +}; + 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) => { + interceptor.intercept(contextFor(201), next).subscribe((result) => { expect(result).toEqual({ status: 201, message: 'Success', @@ -21,9 +37,6 @@ describe('ResponseInterceptor', () => { it('should wrap paginated responses with meta', (done) => { const interceptor = new ResponseInterceptor(); - const context = { - switchToHttp: () => ({ getResponse: () => ({ statusCode: 200 }) }), - } as any; const next = { handle: () => of({ @@ -32,7 +45,7 @@ describe('ResponseInterceptor', () => { }), } as any; - interceptor.intercept(context, next).subscribe((result) => { + interceptor.intercept(contextFor(200), next).subscribe((result) => { expect(result).toEqual({ status: 200, message: 'Success', @@ -42,4 +55,15 @@ describe('ResponseInterceptor', () => { done(); }); }); -}); \ No newline at end of file + + it('should leave a @RawResponse handler untouched', (done) => { + const interceptor = new ResponseInterceptor(); + const body = { type: 'FeatureCollection', features: [] }; + const next = { handle: () => of(body) } as any; + + interceptor.intercept(contextFor(200, true), next).subscribe((result) => { + expect(result).toEqual(body); + done(); + }); + }); +}); diff --git a/apps/api-v2/test/common/queue/queue.service.spec.ts b/apps/api-v2/test/common/queue/queue.service.spec.ts new file mode 100644 index 00000000..2411dfa6 --- /dev/null +++ b/apps/api-v2/test/common/queue/queue.service.spec.ts @@ -0,0 +1,83 @@ +import { WorkerJob } from 'src/common/queue/jobs'; +import { QueueService } from 'src/common/queue/queue.service'; + +const add = jest.fn(); +const close = jest.fn(); +const disconnect = jest.fn(); + +jest.mock('bullmq', () => ({ + Queue: jest.fn().mockImplementation(() => ({ add, close })), +})); + +jest.mock('ioredis', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ disconnect })), +})); + +describe('QueueService', () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.REDIS_URL; + }); + + afterAll(() => { + delete process.env.REDIS_URL; + }); + + describe('without a queue configured', () => { + it('should drop jobs instead of throwing, so a write is never undone by a missing queue', async () => { + const queueService = new QueueService(); + + await expect(queueService.dispatch(WorkerJob.SyncClaimOsm, { claimId: 'claim-1' })).resolves.toBe(false); + expect(add).not.toHaveBeenCalled(); + }); + + it('should close cleanly', async () => { + const queueService = new QueueService(); + + await expect(queueService.onModuleDestroy()).resolves.toBeUndefined(); + }); + }); + + describe('with a queue configured', () => { + beforeEach(() => { + process.env.REDIS_URL = 'redis://localhost:6379'; + }); + + it('should add the job under its worker task name', async () => { + const queueService = new QueueService(); + + await expect(queueService.dispatch(WorkerJob.SyncClaimOsm, { claimId: 'claim-1' })).resolves.toBe(true); + expect(add).toHaveBeenCalledWith('SYNC_CLAIM_OSM', { claimId: 'claim-1' }); + }); + + it('should report a failure rather than propagate it', async () => { + add.mockRejectedValueOnce(new Error('redis is down')); + const queueService = new QueueService(); + + await expect(queueService.dispatch(WorkerJob.SendDiscordLog, {})).resolves.toBe(false); + }); + + it('should count the jobs a batch managed to queue', async () => { + add.mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('redis is down')).mockResolvedValueOnce({}); + const queueService = new QueueService(); + + const queued = await queueService.dispatchAll(WorkerJob.SyncClaimOsm, [ + { claimId: 'claim-1' }, + { claimId: 'claim-2' }, + { claimId: 'claim-3' }, + ]); + + expect(queued).toBe(2); + }); + + it('should close the queue and the connection on shutdown', async () => { + const queueService = new QueueService(); + + await queueService.onModuleDestroy(); + + expect(close).toHaveBeenCalled(); + expect(disconnect).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api-v2/test/sections/claims/claims.controller.spec.ts b/apps/api-v2/test/sections/claims/claims.controller.spec.ts index baa19b0b..438c90a6 100644 --- a/apps/api-v2/test/sections/claims/claims.controller.spec.ts +++ b/apps/api-v2/test/sections/claims/claims.controller.spec.ts @@ -7,76 +7,225 @@ describe('ClaimsController', () => { let claimsController: ClaimsController; let claimsService: { findAll: jest.Mock; + findOne: jest.Mock; + findAllGeoJson: jest.Mock; + findAllImages: jest.Mock; + create: jest.Mock; + importMany: jest.Mock; + update: jest.Mock; + delete: jest.Mock; }; + const pagination = { page: 1, limit: 20 }; + const sorting = { sortBy: 'createdAt', order: 'desc' }; + beforeEach(async () => { claimsService = { findAll: jest.fn(), + findOne: jest.fn(), + findAllGeoJson: jest.fn(), + findAllImages: jest.fn(), + create: jest.fn(), + importMany: jest.fn(), + update: jest.fn(), + delete: jest.fn(), }; const module: TestingModule = await Test.createTestingModule({ controllers: [ClaimsController], - providers: [ - { - provide: ClaimsService, - useValue: claimsService, - }, - ], + 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 () => { + beforeEach(() => { claimsService.findAll.mockResolvedValue({ data: [], meta: { page: 1, perPage: 20, totalItems: 0, totalPages: 0 }, }); + }); - const pagination = { page: 1, limit: 20 }; - const filter = { filter: { active: true } }; + it('should default to the authenticated team when no team is given', async () => { const req = { token: { id: 'team-123' } } as Request; - await claimsController.findAll(pagination as never, filter as never, req); + await claimsController.findAll( + undefined, + pagination as never, + sorting as never, + { filter: { active: true } } as never, + req, + ); + + expect(claimsService.findAll).toHaveBeenCalledWith( + pagination, + { active: true, buildTeamId: 'team-123' }, + 'createdAt', + 'desc', + ); + }); - expect(claimsService.findAll).toHaveBeenCalledWith(pagination, { - active: true, - buildTeamId: 'team-123', - }); + it('should prefer the team in the path over the authenticated team', async () => { + const req = { token: { id: 'team-123' } } as Request; + + await claimsController.findAll('team-999', pagination as never, sorting as never, { filter: {} } as never, req); + + expect(claimsService.findAll).toHaveBeenCalledWith(pagination, { buildTeamId: 'team-999' }, 'createdAt', 'desc'); }); it('should filter by explicit build team id', async () => { - claimsService.findAll.mockResolvedValue({ - data: [], - meta: { page: 1, perPage: 20, totalItems: 0, totalPages: 0 }, - }); + await claimsController.findAll( + undefined, + pagination as never, + sorting as never, + { filter: { team: 'team-456', active: false } } as never, + {} as Request, + ); + + expect(claimsService.findAll).toHaveBeenCalledWith( + pagination, + { active: false, buildTeamId: 'team-456' }, + 'createdAt', + 'desc', + ); + }); - const pagination = { page: 1, limit: 20 }; - const filter = { filter: { team: 'team-456', active: false } }; + it('should filter by team slug when requested', async () => { + await claimsController.findAll( + undefined, + pagination as never, + sorting as never, + { filter: { team: 'build-the-earth', slug: true } } as never, + {} as Request, + ); + + expect(claimsService.findAll).toHaveBeenCalledWith( + pagination, + { buildTeam: { slug: 'build-the-earth' } }, + 'createdAt', + 'desc', + ); + }); - await claimsController.findAll(pagination as never, filter as never, {} as Request); + it('should list every team when there is no team and no token', async () => { + await claimsController.findAll( + undefined, + pagination as never, + sorting as never, + { filter: {} } as never, + {} as Request, + ); - expect(claimsService.findAll).toHaveBeenCalledWith(pagination, { - active: false, - buildTeamId: 'team-456', - }); + expect(claimsService.findAll).toHaveBeenCalledWith(pagination, {}, 'createdAt', 'desc'); }); + }); - it('should filter by team slug when requested', async () => { - claimsService.findAll.mockResolvedValue({ - data: [], - meta: { page: 1, perPage: 20, totalItems: 0, totalPages: 0 }, - }); + describe('findAllGeoJson', () => { + it('should scope to the team in the path and forward the props flag', async () => { + claimsService.findAllGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] }); - const pagination = { page: 1, limit: 20 }; - const filter = { filter: { team: 'build-the-earth', slug: true } }; + await claimsController.findAllGeoJson('team-123', { + filter: { props: true, finished: true }, + } as never); - await claimsController.findAll(pagination as never, filter as never, {} as Request); + expect(claimsService.findAllGeoJson).toHaveBeenCalledWith({ finished: true, buildTeamId: 'team-123' }, true); + }); - expect(claimsService.findAll).toHaveBeenCalledWith(pagination, { - buildTeam: { slug: 'build-the-earth' }, - }); + it('should resolve the team by slug when requested', async () => { + claimsService.findAllGeoJson.mockResolvedValue({ type: 'FeatureCollection', features: [] }); + + await claimsController.findAllGeoJson('my-team', { filter: { slug: true } } as never); + + expect(claimsService.findAllGeoJson).toHaveBeenCalledWith({ buildTeam: { slug: 'my-team' } }, false); + }); + }); + + describe('findAllImages', () => { + it('should scope the images to the authenticated team', async () => { + claimsService.findAllImages.mockResolvedValue({ data: [], meta: {} }); + + await claimsController.findAllImages(pagination as never, { filter: { checked: false } } as never, 'team-123'); + + expect(claimsService.findAllImages).toHaveBeenCalledWith(pagination, 'team-123', false); + }); + }); + + describe('findOne', () => { + it('should look the claim up by id by default', async () => { + claimsService.findOne.mockResolvedValue({ id: 'claim-1' }); + + const result = await claimsController.findOne('claim-1'); + + expect(claimsService.findOne).toHaveBeenCalledWith('claim-1', false, false); + expect(result).toEqual({ id: 'claim-1' }); + }); + + it('should look the claim up by external id when asked to', async () => { + claimsService.findOne.mockResolvedValue({ id: 'claim-1' }); + + await claimsController.findOne('team-internal-42', 'true', 'true'); + + expect(claimsService.findOne).toHaveBeenCalledWith('team-internal-42', true, true); + }); + }); + + describe('create', () => { + it('should create the claim for the authenticated team', async () => { + claimsService.create.mockResolvedValue({ id: 'claim-1' }); + + const dto = { area: ['0, 0', '1, 0', '1, 1'] }; + const result = await claimsController.create(dto, 'team-123'); + + expect(claimsService.create).toHaveBeenCalledWith(dto, 'team-123'); + expect(result).toEqual({ id: 'claim-1' }); + }); + }); + + describe('importClaims', () => { + it('should import the claims for the authenticated team', async () => { + claimsService.importMany.mockResolvedValue({ claims: [], created: 0, updated: 0 }); + + const dtos = [{ area: ['0, 0', '1, 0', '1, 1'], externalId: 'a' }]; + await claimsController.importClaims(dtos, 'team-123'); + + expect(claimsService.importMany).toHaveBeenCalledWith(dtos, 'team-123'); + }); + }); + + describe('update', () => { + it('should update the claim for the authenticated team', async () => { + claimsService.update.mockResolvedValue({ id: 'claim-1' }); + + await claimsController.update('claim-1', { name: 'Updated' }, 'team-123'); + + expect(claimsService.update).toHaveBeenCalledWith('claim-1', false, { name: 'Updated' }, 'team-123'); + }); + + it('should update by external id when asked to', async () => { + claimsService.update.mockResolvedValue({ id: 'claim-1' }); + + await claimsController.update('team-internal-42', { name: 'Updated' }, 'team-123', 'true'); + + expect(claimsService.update).toHaveBeenCalledWith('team-internal-42', true, { name: 'Updated' }, 'team-123'); + }); + }); + + describe('delete', () => { + it('should delete the claim for the authenticated team', async () => { + claimsService.delete.mockResolvedValue({ id: 'claim-1' }); + + await claimsController.delete('claim-1', 'team-123'); + + expect(claimsService.delete).toHaveBeenCalledWith('claim-1', false, 'team-123'); + }); + + it('should delete by external id when asked to', async () => { + claimsService.delete.mockResolvedValue({ id: 'claim-1' }); + + await claimsController.delete('team-internal-42', 'team-123', 'true'); + + expect(claimsService.delete).toHaveBeenCalledWith('team-internal-42', true, 'team-123'); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api-v2/test/sections/claims/claims.routes.spec.ts b/apps/api-v2/test/sections/claims/claims.routes.spec.ts new file mode 100644 index 00000000..cf6a58f1 --- /dev/null +++ b/apps/api-v2/test/sections/claims/claims.routes.spec.ts @@ -0,0 +1,326 @@ +import { INestApplication, ValidationPipe, 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'; + +/** + * Claims are readable by anyone but writable only by the team that owns them, + * and the section carries three route shapes that only resolve correctly once + * the real router is involved: `claims/images` in front of `claims/:id`, the + * `.geojson` listing beside the paginated one, and every route existing both + * bare and behind a `:teamId` prefix. + */ +describe('claim routes', () => { + let app: INestApplication; + let token: string; + let prismaService: { + $connect: jest.Mock; + $transaction: jest.Mock; + claim: { + findMany: jest.Mock; + findFirst: jest.Mock; + count: jest.Mock; + create: jest.Mock; + update: jest.Mock; + delete: jest.Mock; + }; + upload: { findMany: jest.Mock; count: jest.Mock }; + user: { findFirst: jest.Mock }; + }; + + const area = ['0, 0', '0.001, 0', '0.001, 0.001', '0, 0.001']; + + beforeAll(async () => { + process.env.JWT_SECRET = 'test-secret'; + + prismaService = { + $connect: jest.fn(), + $transaction: jest.fn(), + claim: { + findMany: jest.fn(), + findFirst: jest.fn(), + count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + }, + upload: { findMany: jest.fn(), count: jest.fn() }, + user: { findFirst: 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.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + transformOptions: { enableImplicitConversion: true }, + }), + ); + 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.claim.findMany.mockResolvedValue([{ id: 'claim-1', area, finished: true }]); + prismaService.claim.count.mockResolvedValue(1); + prismaService.$transaction.mockImplementation(async (operations: unknown[]) => await Promise.all(operations)); + }); + + it('serves the unscoped listing without a token', async () => { + const response = await request(app.getHttpServer()).get('/v2/claims').expect(200); + + expect(prismaService.claim.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: {} })); + expect(response.body).toEqual({ + status: 200, + message: 'Success', + data: [{ id: 'claim-1', area, finished: true }], + meta: { page: 1, perPage: 20, totalItems: 1, totalPages: 1 }, + }); + }); + + it('scopes the listing to the authenticated team when no team is named', async () => { + await request(app.getHttpServer()).get('/v2/claims').set('Authorization', `Bearer ${token}`).expect(200); + + expect(prismaService.claim.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { buildTeamId: 'team-123' } }), + ); + }); + + it('serves the prefixed listing without a token', async () => { + await request(app.getHttpServer()).get('/v2/team-999/claims').expect(200); + + expect(prismaService.claim.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { buildTeamId: 'team-999' } }), + ); + }); + + it('rejects an unlisted sortBy', async () => { + await request(app.getHttpServer()).get('/v2/claims?sortBy=externalId').expect(400); + }); + + it('answers the geojson listing as raw GeoJSON, without the envelope', async () => { + const response = await request(app.getHttpServer()).get('/v2/claims.geojson').expect(200); + + expect(response.body).toEqual({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: 'claim-1', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [0.001, 0], + [0.001, 0.001], + [0, 0.001], + [0, 0], + ], + ], + }, + properties: { id: 'claim-1', finished: true }, + }, + ], + }); + }); + + it('scopes the geojson listing to the team in the path', async () => { + await request(app.getHttpServer()).get('/v2/team-999/claims.geojson').expect(200); + + expect(prismaService.claim.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { buildTeamId: 'team-999' } }), + ); + }); + + it('routes claims/images to the image listing rather than the single claim', async () => { + prismaService.upload.findMany.mockResolvedValue([{ id: 'upload-1' }]); + prismaService.upload.count.mockResolvedValue(1); + + const response = await request(app.getHttpServer()) + .get('/v2/claims/images') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(prismaService.claim.findFirst).not.toHaveBeenCalled(); + expect(prismaService.upload.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { Claim: { buildTeamId: 'team-123' } } }), + ); + expect(response.body.data).toEqual([{ id: 'upload-1' }]); + }); + + it('rejects the image listing without a token', async () => { + await request(app.getHttpServer()).get('/v2/claims/images').expect(401); + + expect(prismaService.upload.findMany).not.toHaveBeenCalled(); + }); + + it('still routes claims/:id to the single claim', async () => { + prismaService.claim.findFirst.mockResolvedValue({ id: 'claim-1' }); + + await request(app.getHttpServer()).get('/v2/claims/claim-1').expect(200); + + expect(prismaService.claim.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'claim-1' } })); + }); + + it('reads the id as an externalId when asked to', async () => { + prismaService.claim.findFirst.mockResolvedValue({ id: 'claim-1' }); + + await request(app.getHttpServer()).get('/v2/claims/team-internal-42?external=true').expect(200); + + expect(prismaService.claim.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { externalId: 'team-internal-42' } }), + ); + }); + + it('rejects creating a claim without a token', async () => { + await request(app.getHttpServer()).post('/v2/claims').send({ area }).expect(401); + + expect(prismaService.claim.create).not.toHaveBeenCalled(); + }); + + it('creates a claim for the authenticated team', async () => { + prismaService.claim.create.mockResolvedValue({ + id: 'claim-1', + name: 'Claim', + finished: false, + active: true, + createdAt: new Date(), + }); + + const response = await request(app.getHttpServer()) + .post('/v2/team-123/claims') + .set('Authorization', `Bearer ${token}`) + .send({ area, name: 'Claim', active: true }) + .expect(201); + + expect(prismaService.claim.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + area, + center: '0.0005, 0.0005', + name: 'Claim', + active: true, + buildTeam: { connect: { id: 'team-123' } }, + }), + }); + expect(response.body.data).toEqual(expect.objectContaining({ id: 'claim-1' })); + }); + + it('rejects an outline with fewer than three points', async () => { + await request(app.getHttpServer()) + .post('/v2/claims') + .set('Authorization', `Bearer ${token}`) + .send({ area: ['0, 0', '1, 1'] }) + .expect(400); + + expect(prismaService.claim.create).not.toHaveBeenCalled(); + }); + + it('rejects a body with an unknown field', async () => { + await request(app.getHttpServer()) + .post('/v2/claims') + .set('Authorization', `Bearer ${token}`) + .send({ area, buildTeamId: 'someone-else' }) + .expect(400); + }); + + it('routes the import path to the bulk import rather than a claim id', async () => { + prismaService.claim.findMany.mockResolvedValue([]); + prismaService.claim.create.mockResolvedValue({ + id: 'claim-1', + externalId: 'a', + name: '', + finished: false, + active: false, + createdAt: new Date(), + }); + + const response = await request(app.getHttpServer()) + .post('/v2/claims/import') + .set('Authorization', `Bearer ${token}`) + .send([{ area, externalId: 'a' }]) + .expect(201); + + expect(response.body.data).toEqual(expect.objectContaining({ created: 1, updated: 0 })); + }); + + it('rejects an import entry without an externalId', async () => { + await request(app.getHttpServer()) + .post('/v2/claims/import') + .set('Authorization', `Bearer ${token}`) + .send([{ area }]) + .expect(400); + + expect(prismaService.claim.create).not.toHaveBeenCalled(); + }); + + it('refuses a prefix naming a team the token does not belong to', async () => { + await request(app.getHttpServer()) + .put('/v2/someone-else/claims/claim-1') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Updated' }) + .expect(404); + + expect(prismaService.claim.findFirst).not.toHaveBeenCalled(); + }); + + it('updates a claim of the authenticated team', async () => { + prismaService.claim.findFirst.mockResolvedValue({ id: 'claim-1' }); + prismaService.claim.update.mockResolvedValue({ + id: 'claim-1', + name: 'Updated', + finished: false, + active: true, + createdAt: new Date(), + }); + + const response = await request(app.getHttpServer()) + .put('/v2/claims/claim-1') + .set('Authorization', `Bearer ${token}`) + .send({ name: 'Updated' }) + .expect(200); + + expect(response.body.data).toEqual(expect.objectContaining({ name: 'Updated' })); + }); + + it('deletes a claim of the authenticated team', async () => { + prismaService.claim.findFirst.mockResolvedValue({ + id: 'claim-1', + name: 'Claim', + finished: false, + active: true, + createdAt: new Date(), + }); + + await request(app.getHttpServer()).delete('/v2/claims/claim-1').set('Authorization', `Bearer ${token}`).expect(200); + + expect(prismaService.claim.delete).toHaveBeenCalledWith({ where: { id: 'claim-1' } }); + }); + + it('answers 404 when deleting a claim of another team', async () => { + prismaService.claim.findFirst.mockResolvedValue(null); + + await request(app.getHttpServer()).delete('/v2/claims/claim-1').set('Authorization', `Bearer ${token}`).expect(404); + }); +}); diff --git a/apps/api-v2/test/sections/claims/claims.service.spec.ts b/apps/api-v2/test/sections/claims/claims.service.spec.ts index f8102e26..d3180dd4 100644 --- a/apps/api-v2/test/sections/claims/claims.service.spec.ts +++ b/apps/api-v2/test/sections/claims/claims.service.spec.ts @@ -1,43 +1,419 @@ -import { ClaimsService } from 'src/sections/claims/claims.service'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; import { PrismaService } from 'src/common/db/prisma.service'; +import { WorkerJob } from 'src/common/queue/jobs'; +import { QueueService } from 'src/common/queue/queue.service'; +import { ClaimsService, MAX_IMPORT_CLAIMS } from 'src/sections/claims/claims.service'; describe('ClaimsService', () => { let claimsService: ClaimsService; + let queueService: { dispatch: jest.Mock; dispatchAll: jest.Mock }; let prismaService: { + $transaction: jest.Mock; claim: { findMany: jest.Mock; + findFirst: jest.Mock; count: jest.Mock; + create: jest.Mock; + update: jest.Mock; + delete: jest.Mock; }; + upload: { findMany: jest.Mock; count: jest.Mock }; + user: { findFirst: jest.Mock }; }; + const area = ['0, 0', '0.001, 0', '0.001, 0.001', '0, 0.001']; + + /** What the queue is handed for a claim, in the shape announce() reads. */ + const claimRow = (overrides: Record = {}) => ({ + id: 'claim-1', + name: 'Claim', + finished: false, + active: true, + createdAt: new Date('2025-04-19T16:45:18.767Z'), + ...overrides, + }); + beforeEach(() => { prismaService = { + $transaction: jest.fn(), claim: { findMany: jest.fn(), + findFirst: jest.fn(), count: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), }, + upload: { findMany: jest.fn(), count: jest.fn() }, + user: { findFirst: jest.fn() }, }; - claimsService = new ClaimsService(prismaService as unknown as PrismaService); + queueService = { dispatch: jest.fn().mockResolvedValue(true), dispatchAll: jest.fn().mockResolvedValue(0) }; + + claimsService = new ClaimsService( + prismaService as unknown as PrismaService, + queueService as unknown as QueueService, + ); }); - it('should paginate and include claim counts and images', async () => { - prismaService.claim.findMany.mockResolvedValue([{ id: 'claim-1' }]); - prismaService.claim.count.mockResolvedValue(3); + describe('findAll', () => { + it('should paginate, sort 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); + const result = await claimsService.findAll({ page: 2, limit: 1 }, { active: true }, 'name', 'asc'); - 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(prismaService.claim.findMany).toHaveBeenCalledWith({ + where: { active: true }, + orderBy: { name: 'asc' }, + 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 }, + }); }); - expect(result).toEqual({ - data: [{ id: 'claim-1' }], - meta: { page: 2, perPage: 1, totalItems: 3, totalPages: 3 }, + + it('should sort by createdAt descending by default', async () => { + prismaService.claim.findMany.mockResolvedValue([]); + prismaService.claim.count.mockResolvedValue(0); + + await claimsService.findAll({ page: 1, limit: 20 }, {}); + + expect(prismaService.claim.findMany).toHaveBeenCalledWith( + expect.objectContaining({ orderBy: { createdAt: 'desc' } }), + ); + }); + }); + + describe('findOne', () => { + it('should look the claim up by id and leave the builders out by default', async () => { + prismaService.claim.findFirst.mockResolvedValue({ id: 'claim-1' }); + + await claimsService.findOne('claim-1', false, false); + + expect(prismaService.claim.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: { id: 'claim-1' }, + include: expect.objectContaining({ builders: false }), + }), + ); + }); + + it('should look the claim up by external id when asked to', async () => { + prismaService.claim.findFirst.mockResolvedValue({ id: 'claim-1' }); + + await claimsService.findOne('team-internal-42', true, true); + + expect(prismaService.claim.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ where: { externalId: 'team-internal-42' } }), + ); + }); + + it('should throw when the claim does not exist', async () => { + prismaService.claim.findFirst.mockResolvedValue(null); + + await expect(claimsService.findOne('claim-1', false, false)).rejects.toThrow(NotFoundException); + }); + }); + + describe('findAllGeoJson', () => { + it('should build a FeatureCollection of closed polygons', async () => { + prismaService.claim.findMany.mockResolvedValue([{ id: 'claim-1', area, finished: true }]); + + const result = (await claimsService.findAllGeoJson({ finished: true }, false)) as { + type: string; + features: { id: string; geometry: { coordinates: number[][][] }; properties: Record }[]; + }; + + expect(result.type).toBe('FeatureCollection'); + expect(result.features).toHaveLength(1); + expect(result.features[0].geometry.coordinates[0]).toHaveLength(area.length + 1); + expect(result.features[0].properties).not.toHaveProperty('area'); + expect(result.features[0].id).toBe('claim-1'); + }); + + it('should skip claims that have no outline', async () => { + prismaService.claim.findMany.mockResolvedValue([{ id: 'claim-1', area: [], finished: true }]); + + const result = (await claimsService.findAllGeoJson({}, false)) as { features: unknown[] }; + + expect(result.features).toHaveLength(0); + }); + }); + + describe('findAllImages', () => { + it('should scope the images to the claims of the given team', async () => { + prismaService.upload.findMany.mockResolvedValue([{ id: 'upload-1' }]); + prismaService.upload.count.mockResolvedValue(1); + + await claimsService.findAllImages({ page: 1, limit: 20 }, 'team-123', false); + + expect(prismaService.upload.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { Claim: { buildTeamId: 'team-123' }, checked: false }, + orderBy: { createdAt: 'desc' }, + }), + ); + }); + + it('should not filter on checked when it was not asked for', async () => { + prismaService.upload.findMany.mockResolvedValue([]); + prismaService.upload.count.mockResolvedValue(0); + + await claimsService.findAllImages({ page: 1, limit: 20 }, 'team-123'); + + expect(prismaService.upload.findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { Claim: { buildTeamId: 'team-123' } } }), + ); + }); + }); + + describe('create', () => { + it('should store the geometry it can derive locally', async () => { + prismaService.claim.create.mockResolvedValue(claimRow()); + + await claimsService.create({ area, name: 'Claim' }, 'team-123'); + + expect(prismaService.claim.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ + area, + center: '0.0005, 0.0005', + size: expect.any(Number), + name: 'Claim', + buildTeam: { connect: { id: 'team-123' } }, + }), + }); + }); + + it('should leave the OpenStreetMap columns to the worker', async () => { + prismaService.claim.create.mockResolvedValue(claimRow()); + + await claimsService.create({ area }, 'team-123'); + + const { data } = prismaService.claim.create.mock.calls[0][0] as { data: Record }; + expect(data).not.toHaveProperty('osmName'); + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.SyncClaimOsm, { claimId: 'claim-1' }); + }); + + it('should announce the creation to the team webhook and the Discord log', async () => { + prismaService.claim.create.mockResolvedValue(claimRow()); + + await claimsService.create({ area }, 'team-123'); + + expect(queueService.dispatch).toHaveBeenCalledWith( + WorkerJob.BuildTeamWebhook, + expect.objectContaining({ type: 'CLAIM_CREATE', destination: [{ id: 'team-123' }] }), + ); + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.SendDiscordLog, expect.any(Object)); + }); + + it('should resolve an owner named by Minecraft name', async () => { + prismaService.user.findFirst.mockResolvedValue({ id: 'user-1' }); + prismaService.claim.create.mockResolvedValue(claimRow()); + + await claimsService.create({ area, owner: { minecraft: 'Notch' } }, 'team-123'); + + expect(prismaService.user.findFirst).toHaveBeenCalledWith({ + where: { minecraft: 'Notch' }, + select: { id: true }, + }); + expect(prismaService.claim.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ owner: { connect: { id: 'user-1' } } }), + }); + }); + + it('should throw when the named owner does not exist', async () => { + prismaService.user.findFirst.mockResolvedValue(null); + + await expect(claimsService.create({ area, owner: { minecraft: 'Nobody' } }, 'team-123')).rejects.toThrow( + NotFoundException, + ); + expect(prismaService.claim.create).not.toHaveBeenCalled(); + }); + + it('should refuse a user reference that names no field', async () => { + await expect(claimsService.create({ area, owner: {} }, 'team-123')).rejects.toThrow(BadRequestException); + }); + + it('should reject an outline that is not a polygon', async () => { + await expect(claimsService.create({ area: ['0, 0', '1, 1'] }, 'team-123')).rejects.toThrow(BadRequestException); + expect(prismaService.claim.create).not.toHaveBeenCalled(); + }); + }); + + describe('update', () => { + it('should only update claims of the given team', async () => { + prismaService.claim.findFirst.mockResolvedValue({ id: 'claim-1' }); + prismaService.claim.update.mockResolvedValue(claimRow()); + + await claimsService.update('claim-1', false, { name: 'Updated' }, 'team-123'); + + expect(prismaService.claim.findFirst).toHaveBeenCalledWith({ + where: { id: 'claim-1', buildTeamId: 'team-123' }, + select: { id: true }, + }); + expect(prismaService.claim.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'claim-1' } })); + }); + + it('should match on the external id when asked to', async () => { + prismaService.claim.findFirst.mockResolvedValue({ id: 'claim-1' }); + prismaService.claim.update.mockResolvedValue(claimRow()); + + await claimsService.update('team-internal-42', true, { name: 'Updated' }, 'team-123'); + + expect(prismaService.claim.findFirst).toHaveBeenCalledWith({ + where: { externalId: 'team-internal-42', buildTeamId: 'team-123' }, + select: { id: true }, + }); + }); + + it('should not re-run the OpenStreetMap sync when the outline did not change', async () => { + prismaService.claim.findFirst.mockResolvedValue({ id: 'claim-1' }); + prismaService.claim.update.mockResolvedValue(claimRow()); + + await claimsService.update('claim-1', false, { name: 'Updated' }, 'team-123'); + + expect(queueService.dispatch).not.toHaveBeenCalledWith(WorkerJob.SyncClaimOsm, expect.anything()); + expect(queueService.dispatch).toHaveBeenCalledWith( + WorkerJob.BuildTeamWebhook, + expect.objectContaining({ type: 'CLAIM_UPDATE' }), + ); + }); + + it('should re-run the OpenStreetMap sync when the outline changed', async () => { + prismaService.claim.findFirst.mockResolvedValue({ id: 'claim-1' }); + prismaService.claim.update.mockResolvedValue(claimRow()); + + await claimsService.update('claim-1', false, { area }, 'team-123'); + + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.SyncClaimOsm, { claimId: 'claim-1' }); + expect(prismaService.claim.update).toHaveBeenCalledWith( + expect.objectContaining({ data: expect.objectContaining({ center: '0.0005, 0.0005' }) }), + ); + }); + + it('should throw when the claim belongs to another team', async () => { + prismaService.claim.findFirst.mockResolvedValue(null); + + await expect(claimsService.update('claim-1', false, { name: 'Updated' }, 'team-123')).rejects.toThrow( + NotFoundException, + ); + expect(prismaService.claim.update).not.toHaveBeenCalled(); + }); + }); + + describe('delete', () => { + it('should delete the claim and announce it without an OpenStreetMap sync', async () => { + prismaService.claim.findFirst.mockResolvedValue(claimRow()); + + const result = await claimsService.delete('claim-1', false, 'team-123'); + + expect(prismaService.claim.delete).toHaveBeenCalledWith({ where: { id: 'claim-1' } }); + expect(queueService.dispatch).toHaveBeenCalledWith( + WorkerJob.BuildTeamWebhook, + expect.objectContaining({ type: 'CLAIM_DELETE' }), + ); + expect(queueService.dispatch).not.toHaveBeenCalledWith(WorkerJob.SyncClaimOsm, expect.anything()); + expect(result).toEqual(claimRow()); + }); + + it('should throw when the claim belongs to another team', async () => { + prismaService.claim.findFirst.mockResolvedValue(null); + + await expect(claimsService.delete('claim-1', false, 'team-123')).rejects.toThrow(NotFoundException); + expect(prismaService.claim.delete).not.toHaveBeenCalled(); + }); + }); + + describe('importMany', () => { + beforeEach(() => { + prismaService.$transaction.mockImplementation(async (operations: unknown[]) => await Promise.all(operations)); + }); + + it('should update claims it already knows and create the rest', async () => { + prismaService.claim.findMany.mockResolvedValue([{ id: 'claim-1', externalId: 'a', buildTeamId: 'team-123' }]); + prismaService.claim.update.mockResolvedValue(claimRow({ externalId: 'a' })); + prismaService.claim.create.mockResolvedValue(claimRow({ id: 'claim-2', externalId: 'b' })); + + const result = await claimsService.importMany( + [ + { area, externalId: 'a' }, + { area, externalId: 'b' }, + ], + 'team-123', + ); + + expect(prismaService.claim.findMany).toHaveBeenCalledWith({ + where: { externalId: { in: ['a', 'b'] } }, + select: { id: true, externalId: true, buildTeamId: true }, + }); + expect(prismaService.claim.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'claim-1' } })); + expect(prismaService.claim.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ externalId: 'b', buildTeam: { connect: { id: 'team-123' } } }), + }), + ); + expect(result.created).toBe(1); + expect(result.updated).toBe(1); + }); + + it('should queue an OpenStreetMap sync for every imported claim', async () => { + prismaService.claim.findMany.mockResolvedValue([]); + prismaService.claim.create.mockResolvedValue(claimRow({ externalId: 'a' })); + + await claimsService.importMany([{ area, externalId: 'a' }], 'team-123'); + + expect(queueService.dispatch).toHaveBeenCalledWith(WorkerJob.SyncClaimOsm, { claimId: 'claim-1' }); + expect(queueService.dispatch).toHaveBeenCalledWith( + WorkerJob.BuildTeamWebhook, + expect.objectContaining({ type: 'CLAIM_CREATE' }), + ); + }); + + it('should leave claims that are not part of the payload untouched', async () => { + prismaService.claim.findMany.mockResolvedValue([]); + prismaService.claim.create.mockResolvedValue(claimRow({ externalId: 'a' })); + + await claimsService.importMany([{ area, externalId: 'a' }], 'team-123'); + + expect(prismaService.claim.delete).not.toHaveBeenCalled(); + }); + + it('should refuse to touch claims of another team without confirming the id exists', async () => { + prismaService.claim.findMany.mockResolvedValue([{ id: 'claim-1', externalId: 'a', buildTeamId: 'other' }]); + + await expect(claimsService.importMany([{ area, externalId: 'a' }], 'team-123')).rejects.toThrow( + NotFoundException, + ); + expect(prismaService.$transaction).not.toHaveBeenCalled(); + }); + + it('should reject a payload that names the same externalId twice', async () => { + await expect( + claimsService.importMany( + [ + { area, externalId: 'a' }, + { area, externalId: 'a' }, + ], + 'team-123', + ), + ).rejects.toThrow(BadRequestException); + expect(prismaService.claim.findMany).not.toHaveBeenCalled(); + }); + + it('should reject a payload above the import limit before touching the database', async () => { + const tooMany = Array.from({ length: MAX_IMPORT_CLAIMS + 1 }, (_, index) => ({ + area, + externalId: `claim-${index}`, + })); + + await expect(claimsService.importMany(tooMany, 'team-123')).rejects.toThrow(BadRequestException); + expect(prismaService.claim.findMany).not.toHaveBeenCalled(); }); }); -}); \ No newline at end of file +}); diff --git a/apps/api-v2/test/sections/claims/util/area.spec.ts b/apps/api-v2/test/sections/claims/util/area.spec.ts new file mode 100644 index 00000000..4e484a71 --- /dev/null +++ b/apps/api-v2/test/sections/claims/util/area.spec.ts @@ -0,0 +1,74 @@ +import { BadRequestException } from '@nestjs/common'; +import { areaCenter, areaSize, closeRing, parseArea, toGeoJsonRing } from 'src/sections/claims/util/area'; + +describe('claim area helpers', () => { + // Roughly a 100m x 100m box just south of the equator on the prime meridian, + // small enough that the spherical area is easy to sanity check. + const square = ['0, 0', '0.001, 0', '0.001, 0.001', '0, 0.001']; + + describe('parseArea', () => { + it('should read "lng, lat" points', () => { + expect(parseArea(['-73.9857, 40.7484'])).toEqual([[-73.9857, 40.7484]]); + }); + + it('should tolerate missing whitespace', () => { + expect(parseArea(['-73.9857,40.7484'])).toEqual([[-73.9857, 40.7484]]); + }); + + it('should reject a point that is not two numbers', () => { + expect(() => parseArea(['not a point'])).toThrow(BadRequestException); + expect(() => parseArea(['1, 2, 3'])).toThrow(BadRequestException); + }); + + it('should reject coordinates outside the world', () => { + expect(() => parseArea(['200, 0'])).toThrow(BadRequestException); + expect(() => parseArea(['0, 100'])).toThrow(BadRequestException); + }); + }); + + describe('closeRing', () => { + it('should repeat the first point when the ring is open', () => { + expect(closeRing(square)).toEqual([...square, '0, 0']); + }); + + it('should leave an already closed ring alone', () => { + const closed = [...square, '0, 0']; + + expect(closeRing(closed)).toBe(closed); + }); + }); + + describe('areaCenter', () => { + it('should return the centre of the bounding box', () => { + expect(areaCenter(square)).toBe('0.0005, 0.0005'); + }); + + it('should reject an empty area', () => { + expect(() => areaCenter([])).toThrow(BadRequestException); + }); + }); + + describe('areaSize', () => { + it('should return the area in whole square metres', () => { + const size = areaSize(square); + + expect(Number.isInteger(size)).toBe(true); + // ~111m per 0.001 degree at the equator, so ~12,300 m². + expect(size).toBeGreaterThan(11000); + expect(size).toBeLessThan(13500); + }); + + it('should reject an area that cannot describe a polygon', () => { + expect(() => areaSize(['0, 0', '1, 1'])).toThrow(BadRequestException); + }); + }); + + describe('toGeoJsonRing', () => { + it('should close the ring it returns', () => { + const ring = toGeoJsonRing(square); + + expect(ring).toHaveLength(square.length + 1); + expect(ring[0]).toEqual(ring[ring.length - 1]); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 1ccb4027..b8d4597c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2152,6 +2152,13 @@ __metadata: languageName: node linkType: hard +"@ioredis/commands@npm:1.10.0": + version: 1.10.0 + resolution: "@ioredis/commands@npm:1.10.0" + checksum: 10c0/baf91e62d0e64ef2b5f7ca4413dc2456fe250e87483beac4a1c8ef1fe5ad0d2fcdeb9b89d4556d8ef6c7455c64a964359d729601fdb06b2f4c76c35dd59afa99 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -2962,6 +2969,48 @@ __metadata: languageName: node linkType: hard +"@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-arm64@npm:3.0.4" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-darwin-x64@npm:3.0.4" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm64@npm:3.0.4" + conditions: os=linux & cpu=arm64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-linux-arm@npm:3.0.4" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-linux-x64@npm:3.0.4" + conditions: os=linux & cpu=x64 + languageName: node + linkType: hard + +"@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4": + version: 3.0.4 + resolution: "@msgpackr-extract/msgpackr-extract-win32-x64@npm:3.0.4" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@napi-rs/nice-android-arm-eabi@npm:1.0.1": version: 1.0.1 resolution: "@napi-rs/nice-android-arm-eabi@npm:1.0.1" @@ -9862,16 +9911,20 @@ __metadata: "@repo/typescript-config": "npm:*" "@swc/cli": "npm:^0.6.0" "@swc/core": "npm:^1.10.7" + "@turf/area": "npm:^7.2.0" + "@turf/helpers": "npm:^7.2.0" "@types/express": "npm:^5.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" + bullmq: "npm:^5.77.0" class-transformer: "npm:^0.5.1" class-validator: "npm:^0.14.2" eslint: "npm:^9.18.0" globals: "npm:^16.0.0" helmet: "npm:^8.1.0" + ioredis: "npm:^5.10.1" jest: "npm:^29.7.0" reflect-metadata: "npm:^0.2.2" rxjs: "npm:^7.8.1" @@ -10700,6 +10753,25 @@ __metadata: languageName: node linkType: hard +"bullmq@npm:^5.77.0": + version: 5.81.4 + resolution: "bullmq@npm:5.81.4" + dependencies: + cron-parser: "npm:4.9.0" + ioredis: "npm:5.11.1" + msgpackr: "npm:2.0.5" + node-abort-controller: "npm:3.1.1" + semver: "npm:7.8.5" + tslib: "npm:2.8.1" + peerDependencies: + redis: ">=5.0.0" + peerDependenciesMeta: + redis: + optional: true + checksum: 10c0/c10a276316bd50130b44c490aa73ca416c8aa5417c2ef8d49a1dba71c1bf8bfdb80a80f8c34a335c8477228b3b9e35d73b4572977590f5e83c2d26eb389b1385 + languageName: node + linkType: hard + "busboy@npm:1.6.0, busboy@npm:^1.0.0, busboy@npm:^1.6.0": version: 1.6.0 resolution: "busboy@npm:1.6.0" @@ -11112,6 +11184,13 @@ __metadata: languageName: node linkType: hard +"cluster-key-slot@npm:1.1.1": + version: 1.1.1 + resolution: "cluster-key-slot@npm:1.1.1" + checksum: 10c0/079b1ae86b20e2d53308a877b08de5e830722a45c07810569d0dab4955bed569da33ac9f79998289d014adf02cca7223a0647cb0ee6548a12ab3c4f9beac1377 + languageName: node + linkType: hard + "co@npm:^4.6.0": version: 4.6.0 resolution: "co@npm:4.6.0" @@ -11523,6 +11602,15 @@ __metadata: languageName: node linkType: hard +"cron-parser@npm:4.9.0": + version: 4.9.0 + resolution: "cron-parser@npm:4.9.0" + dependencies: + luxon: "npm:^3.2.1" + checksum: 10c0/348622bdcd1a15695b61fc33af8a60133e5913a85cf99f6344367579e7002896514ba3b0a9d6bb569b02667d6b06836722bf2295fcd101b3de378f71d37bed0b + languageName: node + linkType: hard + "cron@npm:^4.1.4": version: 4.1.4 resolution: "cron@npm:4.1.4" @@ -11895,16 +11983,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:^3.2.7": - version: 3.2.7 - resolution: "debug@npm:3.2.7" - dependencies: - ms: "npm:^2.1.1" - checksum: 10c0/37d96ae42cbc71c14844d2ae3ba55adf462ec89fd3a999459dec3833944cd999af6007ff29c780f1c61153bcaaf2c842d1e4ce1ec621e4fc4923244942e4a02a - languageName: node - linkType: hard - -"debug@npm:^4": +"debug@npm:4.4.3, debug@npm:^4": version: 4.4.3 resolution: "debug@npm:4.4.3" dependencies: @@ -11916,6 +11995,15 @@ __metadata: languageName: node linkType: hard +"debug@npm:^3.2.7": + version: 3.2.7 + resolution: "debug@npm:3.2.7" + dependencies: + ms: "npm:^2.1.1" + checksum: 10c0/37d96ae42cbc71c14844d2ae3ba55adf462ec89fd3a999459dec3833944cd999af6007ff29c780f1c61153bcaaf2c842d1e4ce1ec621e4fc4923244942e4a02a + languageName: node + linkType: hard + "debug@npm:^4.1.0": version: 4.4.1 resolution: "debug@npm:4.4.1" @@ -12066,6 +12154,13 @@ __metadata: languageName: node linkType: hard +"denque@npm:2.1.0": + version: 2.1.0 + resolution: "denque@npm:2.1.0" + checksum: 10c0/f9ef81aa0af9c6c614a727cb3bd13c5d7db2af1abf9e6352045b86e85873e629690f6222f4edd49d10e4ccf8f078bbeec0794fafaf61b659c0589d0c511ec363 + languageName: node + linkType: hard + "density-clustering@npm:1.3.0": version: 1.3.0 resolution: "density-clustering@npm:1.3.0" @@ -12094,6 +12189,13 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^2.0.1": + version: 2.1.2 + resolution: "detect-libc@npm:2.1.2" + checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4 + languageName: node + linkType: hard + "detect-newline@npm:^3.0.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -15367,6 +15469,21 @@ __metadata: languageName: node linkType: hard +"ioredis@npm:5.11.1, ioredis@npm:^5.10.1": + version: 5.11.1 + resolution: "ioredis@npm:5.11.1" + dependencies: + "@ioredis/commands": "npm:1.10.0" + cluster-key-slot: "npm:1.1.1" + debug: "npm:4.4.3" + denque: "npm:2.1.0" + redis-errors: "npm:1.2.0" + redis-parser: "npm:3.0.0" + standard-as-callback: "npm:2.1.0" + checksum: 10c0/a8b27043cf2c045dfc93f40a32ce24cf9f8b57799a37f4234c4b925c365ccf131629590f94a512f546fda2ba8ed034009c94c4933ecd44c50bc166636d929fd6 + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -17197,6 +17314,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:^3.2.1": + version: 3.7.2 + resolution: "luxon@npm:3.7.2" + checksum: 10c0/ed8f0f637826c08c343a29dd478b00628be93bba6f068417b1d8896b61cb61c6deacbe1df1e057dbd9298334044afa150f9aaabbeb3181418ac8520acfdc2ae2 + languageName: node + linkType: hard + "luxon@npm:~3.6.0": version: 3.6.1 resolution: "luxon@npm:3.6.1" @@ -17862,6 +17986,49 @@ __metadata: languageName: node linkType: hard +"msgpackr-extract@npm:^3.0.4": + version: 3.0.4 + resolution: "msgpackr-extract@npm:3.0.4" + dependencies: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-darwin-x64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-arm64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-linux-x64": "npm:3.0.4" + "@msgpackr-extract/msgpackr-extract-win32-x64": "npm:3.0.4" + node-gyp: "npm:latest" + node-gyp-build-optional-packages: "npm:5.2.2" + dependenciesMeta: + "@msgpackr-extract/msgpackr-extract-darwin-arm64": + optional: true + "@msgpackr-extract/msgpackr-extract-darwin-x64": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-arm": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-arm64": + optional: true + "@msgpackr-extract/msgpackr-extract-linux-x64": + optional: true + "@msgpackr-extract/msgpackr-extract-win32-x64": + optional: true + bin: + download-msgpackr-prebuilds: bin/download-prebuilds.js + checksum: 10c0/582a9d17abbf3019e600e948736695056280ce401fd0235ee2474e95f9952208b9f6cce4d0e355b03b7d3c5630e6c3d11fe5fc27fdedb2311cce48de464338d8 + languageName: node + linkType: hard + +"msgpackr@npm:2.0.5": + version: 2.0.5 + resolution: "msgpackr@npm:2.0.5" + dependencies: + msgpackr-extract: "npm:^3.0.4" + dependenciesMeta: + msgpackr-extract: + optional: true + checksum: 10c0/7ac9820cecd44d24d2ef07994405a277509f004d4fb0a77d04e10e9071d0599e86ad53a9daf673e801518230774bdaca72b1fdcff2b516584ab58bd19362e6ad + languageName: node + linkType: hard + "multer@npm:2.0.1": version: 2.0.1 resolution: "multer@npm:2.0.1" @@ -18165,7 +18332,7 @@ __metadata: languageName: node linkType: hard -"node-abort-controller@npm:^3.0.1": +"node-abort-controller@npm:3.1.1, node-abort-controller@npm:^3.0.1": version: 3.1.1 resolution: "node-abort-controller@npm:3.1.1" checksum: 10c0/f7ad0e7a8e33809d4f3a0d1d65036a711c39e9d23e0319d80ebe076b9a3b4432b4d6b86a7fab65521de3f6872ffed36fc35d1327487c48eb88c517803403eda3 @@ -18190,6 +18357,19 @@ __metadata: languageName: node linkType: hard +"node-gyp-build-optional-packages@npm:5.2.2": + version: 5.2.2 + resolution: "node-gyp-build-optional-packages@npm:5.2.2" + dependencies: + detect-libc: "npm:^2.0.1" + bin: + node-gyp-build-optional-packages: bin.js + node-gyp-build-optional-packages-optional: optional.js + node-gyp-build-optional-packages-test: build-test.js + checksum: 10c0/c81128c6f91873381be178c5eddcbdf66a148a6a89a427ce2bcd457593ce69baf2a8662b6d22cac092d24aa9c43c230dec4e69b3a0da604503f4777cd77e282b + languageName: node + linkType: hard + "node-gyp@npm:latest": version: 11.2.0 resolution: "node-gyp@npm:11.2.0" @@ -20028,6 +20208,22 @@ __metadata: languageName: node linkType: hard +"redis-errors@npm:1.2.0, redis-errors@npm:^1.0.0": + version: 1.2.0 + resolution: "redis-errors@npm:1.2.0" + checksum: 10c0/5b316736e9f532d91a35bff631335137a4f974927bb2fb42bf8c2f18879173a211787db8ac4c3fde8f75ed6233eb0888e55d52510b5620e30d69d7d719c8b8a7 + languageName: node + linkType: hard + +"redis-parser@npm:3.0.0": + version: 3.0.0 + resolution: "redis-parser@npm:3.0.0" + dependencies: + redis-errors: "npm:^1.0.0" + checksum: 10c0/ee16ac4c7b2a60b1f42a2cdaee22b005bd4453eb2d0588b8a4939718997ae269da717434da5d570fe0b05030466eeb3f902a58cf2e8e1ca058bf6c9c596f632f + languageName: node + linkType: hard + "reflect-metadata@npm:^0.2.2": version: 0.2.2 resolution: "reflect-metadata@npm:0.2.2" @@ -20569,6 +20765,15 @@ __metadata: languageName: node linkType: hard +"semver@npm:7.8.5": + version: 7.8.5 + resolution: "semver@npm:7.8.5" + bin: + semver: bin/semver.js + checksum: 10c0/b1f3127a5be8125a94f37188b361c212466c292c6910adce3ec106cff5dc211ccaedc4739c11bb70fda59d6fc1f040a9bca289f4e093451521a2372e5231fe0c + languageName: node + linkType: hard + "semver@npm:^5.7.2": version: 5.7.2 resolution: "semver@npm:5.7.2" @@ -21233,6 +21438,13 @@ __metadata: languageName: node linkType: hard +"standard-as-callback@npm:2.1.0": + version: 2.1.0 + resolution: "standard-as-callback@npm:2.1.0" + checksum: 10c0/012677236e3d3fdc5689d29e64ea8a599331c4babe86956bf92fc5e127d53f85411c5536ee0079c52c43beb0026b5ce7aa1d834dd35dd026e82a15d1bcaead1f + languageName: node + linkType: hard + "statuses@npm:2.0.1, statuses@npm:^2.0.1": version: 2.0.1 resolution: "statuses@npm:2.0.1"