Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
dbba454
feat(api/v2): :sparkles: Implement GET /applications/:id
kyanvde May 20, 2026
70d10f4
feat(api/v2): :sparkles: Implement PUT /applications/:id
kyanvde May 20, 2026
84107e7
fix(api/v2): :bug: Fix ErrorResponseDto not being registered in swagger
kyanvde May 20, 2026
659133d
feat(api/v2): :sparkles: Implement GET /applications/questions
kyanvde May 20, 2026
be6737b
test(api/v2): :white_check_mark: Add UtilityController tests (#117)
kyanvde May 20, 2026
177833b
feat(api/v2): :sparkles: Implement DEL /applications/questions/:id (a…
kyanvde May 21, 2026
6a0e79c
test(api/v2): :white_check_mark: Add tests for almost all existing ap…
kyanvde Jun 20, 2026
9d85653
chore(mono): :lock: Sync lockfile with api-v2 dependencies
kyanvde Aug 20, 2026
51d60d4
style(api/v2): :art: Drop accidental prettier override and format src
kyanvde Aug 20, 2026
f8589c7
fix(api/v2): :bug: Return 404 when deleting an unknown question
kyanvde Aug 20, 2026
b7967db
feat(api/v2): :sparkles: Implement application question write routes
kyanvde Aug 20, 2026
6c0a9a8
feat(api/v2): :sparkles: Implement GET /:teamId/applications/questions
kyanvde Aug 20, 2026
678ead5
feat(api/v2): :sparkles: Implement application response template routes
kyanvde Aug 20, 2026
10912c8
test(api/v2): :white_check_mark: Cover application route registration…
kyanvde Aug 20, 2026
ca11a3e
docs(mono): :memo: Add CLAUDE.md with repo guidance
kyanvde Aug 20, 2026
989c522
Merge branch 'api/v2' into api-v2/applications
kyanvde Aug 20, 2026
82d6f3a
fix(api/v2): :lock: Scope application lookup and review to the owning…
kyanvde Aug 20, 2026
b97e6f0
fix(api/v2): :bug: Reject invalid filter values with 400 instead of 500
kyanvde Aug 20, 2026
f18ea0e
fix(api/v2): :lock: Harden the bulk question upsert
kyanvde Aug 20, 2026
0370939
fix(api/v2): :bug: Honour createdAt when creating an application
kyanvde Aug 20, 2026
a5ddbbe
fix(api/v2): :bug: Stop truncating the public application form
kyanvde Aug 20, 2026
79b0871
chore(mono): :lock: Re-sync lockfile after the api/v2 merge
kyanvde Aug 20, 2026
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
89 changes: 89 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Repository

Turborepo monorepo (Yarn 4 workspaces) for BuildTheEarth's web presence: `apps/*` (frontend, dashboard, api, api-v2) and `packages/*` (db, prettier-config, typescript-config). TypeScript only — ignore/do not add JavaScript files.

## Commands

All commands run from the repo root. `yarn ws <workspace> <script>` is an alias for `yarn workspace`.

```bash
yarn install # install (Yarn 4, never npm/pnpm)
yarn dev # all apps (turbo) — starts docker postgres, waits, migrates, then dev
yarn dev:frontend # frontend only
yarn build # all (turbo caches; only changed packages rebuild)
yarn build:api # or build:frontend
yarn lint # turbo lint across workspaces
yarn prettier # format all workspaces
```

Database (`packages/db` owns the single Prisma schema):

```bash
yarn generate:db # prisma generate — required after schema changes or a fresh clone
yarn migrate:db # prisma migrate deploy
yarn studio:db # prisma studio
yarn ws @repo/db up # docker compose up -d (postgres:18 on :5432)
yarn ws @repo/db pull:db # introspect an existing database into the schema
yarn git:pull # git pull + install + generate:db
```

Each app has its own `env:copy` script (`yarn ws frontend env:copy`); `packages/db` and `apps/api-v2` also ship `.env.example`. The README's root-level `yarn env:copy` does not exist.

### Tests

Only `apps/api-v2` has tests (Jest + ts-jest). There is no turbo `test` task, so run them through the workspace:

```bash
yarn ws api-v2 test # all specs
yarn ws api-v2 test test/sections/claims # by path
yarn ws api-v2 test -t "should apply pagination" # single test by name
yarn ws api-v2 test:watch
yarn ws api-v2 test:cov
```

Jest's `testRegex` is `test/.*\.spec\.ts$` — specs live in `apps/api-v2/test/`, mirroring `src/`, never beside the source file. `^src/(.*)$` is mapped, so specs import from `src/...`.

On a fresh clone the suite fails to resolve `@repo/db` until that package has been compiled, because it resolves through `main: dist/index.js`. Run `yarn ws @repo/db build` (after `yarn generate:db`) once before running tests. `eslint` currently reports 6 pre-existing `@typescript-eslint/unbound-method` errors in `test/bootstrap/main.spec.ts`, `test/common/db/prisma.service.spec.ts` and the three `test/common/decorators/*` specs, so a non-zero `lint` exit is not necessarily caused by your change.

## Architecture

### Two API generations

`apps/api` (v1, in production) and `apps/api-v2` (v2, actively being built) are separate services with different stacks and auth models. New API work goes in `api-v2` unless the task explicitly concerns v1.

**`apps/api` (v1)** — Express 5, ESM (`"type": "module"`, so relative imports need `.js` extensions). A single `Core` class (`src/Core.ts`) constructs and owns every subsystem — Winston logger, Keycloak + KeycloakAdmin, Prisma, AWS S3, Discord integration, cron jobs — and hands itself to `Web` (`src/web/Web.ts`), which instantiates all controllers and registers routes via `src/web/routes/index.ts` using a `Router` helper. Auth is Keycloak (`keycloak-connect`) plus permission middleware in `src/web/routes/utils/`. Validation is express-validator/yup declared inline at route registration. Everything is served under `/api/v1`.

**`apps/api-v2`** — NestJS 11, URI versioning with default version `2` (routes are `/v2/...`), Swagger at `/v2/docs` (`docs.json` / `docs.yaml`). Source layout: `src/sections/<name>/` holds `<name>.module.ts`, `.controller.ts`, `.service.ts` and `dto/`; nested resources nest further (`sections/applications/questions/`). Cross-cutting code lives in `src/common/{db,decorators,dto,guards,interceptors}`.

Conventions that matter when adding an api-v2 endpoint:

- **Response envelope** is applied globally by `ResponseInterceptor`. Controllers return raw data (typed `ControllerResponse`) and get `{ status, message, data }`; returning `{ data, meta }` (typed `PaginatedControllerResponse`) yields the paginated envelope. Errors go through `ExceptionsFilter` as `{ status, timestamp, path, error, message }` — throw Nest HTTP exceptions rather than shaping errors by hand.
- **Query features come in decorator pairs**: a method decorator declaring options + Swagger params (`@Paginated`, `@Sortable`, `@Filtered`) and a param decorator reading them back off reflector metadata (`@Pagination`, `@Sorting`, `@Filter`). Both must be present, and `@Sortable`'s `allowedFields` is enforced — an unlisted `sortBy` throws 400.
- **Swagger schemas** use `@ApiDefaultResponse(Dto)`, `@ApiPaginatedResponseDto(Dto)`, `@ApiErrorResponse({ status, description })` from `common/decorators/api-response.decorator.ts` rather than raw `@ApiResponse`.
- **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.
- `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.

### Frontends

`apps/frontend` (public site, port 3000) and `apps/dashboard` (team/admin dashboard, port 3001) are both Next.js 15 + Mantine 7 + Mapbox, authenticated with next-auth against Keycloak and gated by `src/middleware.ts`. They differ in data access: the frontend is a pages-router app that talks to the v1 API over HTTP via `src/utils/Fetcher.tsx` + SWR, while the dashboard is an app-router app that uses server actions in `src/actions/` querying Prisma directly through the singleton in `src/util/db.ts` (which adds a computed `upload.src` CDN URL). The frontend is localized with next-i18next/Crowdin.

### Shared packages

`packages/db` is the only place a Prisma schema exists; it re-exports the entire generated client (`export * from '@prisma/client'`) so apps import types and enums from `@repo/db`, never from `@prisma/client`. Turbo makes `build` depend on `generate:db`. `packages/typescript-config` exposes `base/expressjs/nestjs/nextjs` presets; `packages/prettier-config` is the root `prettier` config.

## Conventions

- **Commits**: conventional commits with a gitmoji, scope `<subrepo>/<scope>` — e.g. `feat(api/v2): :sparkles: Implement GET /applications/:id`. Use `mono` as the scope for repo-wide changes.
- **Formatting**: Prettier with tabs, single quotes, 120 columns, from `@repo/prettier-config`, everywhere. `apps/api-v2` does not declare `prettier` itself, so its `yarn ws api-v2 prettier` script fails with "command not found" — run `npx prettier <paths> --write` from the repo root instead. `apps/api-v2/test/` is not covered by that script's `./src` glob and is currently unformatted.
- `apps/api-v2` lint is `eslint --fix` with type-checked rules; the other apps lint with `--max-warnings 0`.

## CI/CD

Pushes to `main` trigger per-app GitHub Actions (`.github/workflows/{api,dashboard,frontend}.yml`) that use `turbo-ignore` to skip unchanged apps, then build the app's Dockerfile and push to ghcr.io. `apps/api-v2` has no Dockerfile or workflow yet — it is not deployed.
1 change: 1 addition & 0 deletions apps/api-v2/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
JWT_SECRET=topsecret
36 changes: 36 additions & 0 deletions apps/api-v2/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { Config } from 'jest';

const config: Config = {
moduleFileExtensions: ['js', 'json', 'ts'],

// IMPORTANT: make project root stable
rootDir: '.',

testRegex: 'test/.*\\.spec\\.ts$',

transform: {
'^.+\\.(t|j)s$': [
'ts-jest',
{
tsconfig: '<rootDir>/tsconfig.spec.json',
},
],
},

moduleNameMapper: {
'^src/(.*)$': '<rootDir>/src/$1',
},

collectCoverageFrom: [
'src/**/*.{ts,js}',
'!src/**/*.spec.{ts,js}',
'!src/**/*.test.{ts,js}',
'!src/main.ts',
],

coverageDirectory: '<rootDir>/coverage',

testEnvironment: 'node',
};

export default config;
27 changes: 0 additions & 27 deletions apps/api-v2/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,32 +62,5 @@
"prettier": "@repo/prettier-config",
"lint-staged": {
"*": "prettier --write --ignore-unknown"
},
"jest": {
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"rootDir": "test",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.(t|j)s$": [
"ts-jest",
{
"tsconfig": "<rootDir>/../tsconfig.spec.json"
}
]
},
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/../src/$1"
},
"collectCoverageFrom": [
"../src/**/*.{ts,js}",
"!../src/**/*.spec.{ts,js}",
"!../src/**/*.test.{ts,js}"
],
"coverageDirectory": "../coverage",
"testEnvironment": "node"
}
}
1 change: 0 additions & 1 deletion apps/api-v2/src/.prettierrc

This file was deleted.

45 changes: 26 additions & 19 deletions apps/api-v2/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { APP_GUARD } from "@nestjs/core";
import { PrismaService } from "./common/db/prisma.service";
import { AuthGuard } from "./common/guards/auth.guard";
import { ApplicationsModule } from "./sections/applications/applications.module";
import { AuthModule } from "./sections/auth/auth.module";
import { StatusModule } from "./sections/status/status.module";
import { UtilityModule } from "./sections/utility/utility.module";
import { ClaimsModule } from "./sections/claims/claims.module";
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { PrismaService } from './common/db/prisma.service';
import { AuthGuard } from './common/guards/auth.guard';
import { ApplicationQuestionsModule } from './sections/applications/questions/application-questions.module';
import { ApplicationsModule } from './sections/applications/applications.module';
import { ApplicationTemplatesModule } from './sections/applications/templates/application-templates.module';
import { AuthModule } from './sections/auth/auth.module';
import { ClaimsModule } from './sections/claims/claims.module';
import { StatusModule } from './sections/status/status.module';
import { UtilityModule } from './sections/utility/utility.module';

@Module({
imports: [
ApplicationsModule,
AuthModule,
ClaimsModule,
ConfigModule.forRoot({ isGlobal: true, cache: true }),
StatusModule,
UtilityModule,
],
providers: [PrismaService, { provide: APP_GUARD, useClass: AuthGuard }],
imports: [
// Routes are matched in the order their modules are registered, so both of these
// have to stay in front of ApplicationsModule. Otherwise /applications/:id would
// swallow /applications/questions and /applications/templates.
ApplicationQuestionsModule,
ApplicationTemplatesModule,
ApplicationsModule,
AuthModule,
ClaimsModule,
ConfigModule.forRoot({ isGlobal: true, cache: true }),
StatusModule,
UtilityModule,
],
providers: [PrismaService, { provide: APP_GUARD, useClass: AuthGuard }],
})
export class AppModule {}
115 changes: 47 additions & 68 deletions apps/api-v2/src/common/db/external/cachet.service.ts
Original file line number Diff line number Diff line change
@@ -1,75 +1,54 @@
import { HttpService } from "@nestjs/axios";
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { firstValueFrom } from "rxjs";
import { HttpService } from '@nestjs/axios';
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { firstValueFrom } from 'rxjs';

/**
*
*/
@Injectable()
export class CachetAPIService {
private readonly logger = new Logger(CachetAPIService.name);
private baseURL: string | undefined;
private apiToken: string | undefined;

constructor(
private readonly http: HttpService,
private configService: ConfigService,
) {
if (
!this.configService.get<string>("CACHET_URL") ||
!this.configService.get<string>("CACHET_TOKEN")
) {
this.logger.warn(
"Cachet configuration is missing. CachetAPIService will not function properly.",
);
} else {
this.baseURL = this.configService.get<string>("CACHET_URL") as string;
this.apiToken = this.configService.get<string>("CACHET_TOKEN") as string;

this.testConnection().then(() => {
this.logger.log("Cachet API is reachable");
});
}
}

async testConnection(): Promise<string> {
return (await firstValueFrom(this.http.get(`${this.baseURL}/api/ping`)))
.data.data;
}

async getGlobalStatus(): Promise<{ status: string; message: string }> {
return (await firstValueFrom(this.http.get(`${this.baseURL}/api/status`)))
.data.data;
}

async getComponents({
status,
}: {
status?: 1 | 2 | 3 | 4 | 5 | 6;
}): Promise<any[]> {
return (
await firstValueFrom(
this.http.get(
`${this.baseURL}/api/components?per_page=30${status ? `&filter%5Bstatus%5D=${status}` : ""}`,
),
)
).data.data;
}

async getComponentGroups(): Promise<any[]> {
return (
await firstValueFrom(
this.http.get(`${this.baseURL}/api/component-groups`),
)
).data.data;
}

async getIncidents(): Promise<any[]> {
return (
await firstValueFrom(
this.http.get(`${this.baseURL}/api/incidents?per_page=30`),
)
).data.data;
}
private readonly logger = new Logger(CachetAPIService.name);
private baseURL: string | undefined;
private apiToken: string | undefined;

constructor(
private readonly http: HttpService,
private configService: ConfigService,
) {
if (!this.configService.get<string>('CACHET_URL') || !this.configService.get<string>('CACHET_TOKEN')) {
this.logger.warn('Cachet configuration is missing. CachetAPIService will not function properly.');
} else {
this.baseURL = this.configService.get<string>('CACHET_URL') as string;
this.apiToken = this.configService.get<string>('CACHET_TOKEN') as string;

this.testConnection().then(() => {
this.logger.log('Cachet API is reachable');
});
}
}

async testConnection(): Promise<string> {
return (await firstValueFrom(this.http.get(`${this.baseURL}/api/ping`))).data.data;
}

async getGlobalStatus(): Promise<{ status: string; message: string }> {
return (await firstValueFrom(this.http.get(`${this.baseURL}/api/status`))).data.data;
}

async getComponents({ status }: { status?: 1 | 2 | 3 | 4 | 5 | 6 }): Promise<any[]> {
return (
await firstValueFrom(
this.http.get(`${this.baseURL}/api/components?per_page=30${status ? `&filter%5Bstatus%5D=${status}` : ''}`),
)
).data.data;
}

async getComponentGroups(): Promise<any[]> {
return (await firstValueFrom(this.http.get(`${this.baseURL}/api/component-groups`))).data.data;
}

async getIncidents(): Promise<any[]> {
return (await firstValueFrom(this.http.get(`${this.baseURL}/api/incidents?per_page=30`))).data.data;
}
}
10 changes: 5 additions & 5 deletions apps/api-v2/src/common/db/prisma.service.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { Injectable, OnModuleInit } from "@nestjs/common";
import { PrismaClient } from "@repo/db";
import { Injectable, OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@repo/db';

/**
* PrismaService is a wrapper around PrismaClient that handles connection management.
* It implements OnModuleInit to connect to the database when the module is initialized.
*/
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit {
async onModuleInit() {
await this.$connect();
}
async onModuleInit() {
await this.$connect();
}
}
Loading