Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
9 changes: 8 additions & 1 deletion apps/api-v2/.env.example
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
JWT_SECRET=topsecret
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
4 changes: 4 additions & 0 deletions apps/api-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
20 changes: 10 additions & 10 deletions apps/api-v2/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] \
)

Expand Down
2 changes: 2 additions & 0 deletions apps/api-v2/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -23,6 +24,7 @@ import { UtilityModule } from './sections/utility/utility.module';
AuthModule,
ClaimsModule,
ConfigModule.forRoot({ isGlobal: true, cache: true }),
QueueModule,
SocialsModule,
StatusModule,
UtilityModule,
Expand Down
13 changes: 13 additions & 0 deletions apps/api-v2/src/common/decorators/raw-response.decorator.ts
Original file line number Diff line number Diff line change
@@ -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);
16 changes: 16 additions & 0 deletions apps/api-v2/src/common/interceptors/response.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<T> implements NestInterceptor<T, Response<T>> {
private readonly reflector = new Reflector();

intercept(context: ExecutionContext, next: CallHandler): Observable<Response<T>> {
const isRaw = this.reflector.getAllAndOverride<boolean>(IS_RAW_RESPONSE_KEY, [
context.getHandler(),
context.getClass(),
]);

if (isRaw) {
return next.handle() as Observable<Response<T>>;
}

return next.handle().pipe(
map((data: GenericControllerResponse<T>) => {
const status: number = Number(context.switchToHttp().getResponse().statusCode);
Expand Down
58 changes: 58 additions & 0 deletions apps/api-v2/src/common/queue/jobs.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
[WorkerJob.SendDiscordDm]: {
userId?: string;
userIds?: string[];
discordId?: string;
discordIds?: string[];
content: unknown;
};
[WorkerJob.RevalidateWebsite]: { paths?: string[]; tags?: string[] };
}
14 changes: 14 additions & 0 deletions apps/api-v2/src/common/queue/queue.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
89 changes: 89 additions & 0 deletions apps/api-v2/src/common/queue/queue.service.ts
Original file line number Diff line number Diff line change
@@ -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<N extends WorkerJob>(name: N, payload: WorkerJobPayloads[N]): Promise<boolean> {
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<N extends WorkerJob>(name: N, payloads: WorkerJobPayloads[N][]): Promise<number> {
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();
}
}
Loading