diff --git a/.env.dist b/.env.dist index aaa929c9..d359cf27 100644 --- a/.env.dist +++ b/.env.dist @@ -34,17 +34,17 @@ WEATHER_API_HOST="http://weather-mock:1083" SAY_INTENTIONS_API_HOST="http://sayintentions-mock:1085" -AEROLOPA_FUNCTION_BASE_URL="http://functions-mock:1087/aerolopa-provider/aerolopa" +AEROLOPA_FUNCTION_BASE_URL="http://functions-mock:1087" AEROLOPA_FUNCTION_SECRET="very-secret-token" -OSM_FUNCTION_BASE_URL="http://functions-mock:1088/osm-provider/airport" +OSM_FUNCTION_BASE_URL="http://functions-mock:1088" OSM_FUNCTION_SECRET="very-secret-token" -POSTCARD_FUNCTION_BASE_URL="http://functions-mock:1089/postcard-generator/postcard" +POSTCARD_FUNCTION_BASE_URL="http://functions-mock:1089" POSTCARD_FUNCTION_SECRET="very-secret-token" POSTCARD_ART_BASE_URL="http://functions-mock:1089/mypreflight-files" -OCEANIC_TRACKS_FUNCTION_BASE_URL="http://functions-mock:1090/oceanic-tracks-provider/oceanic-tracks" +OCEANIC_TRACKS_FUNCTION_BASE_URL="http://functions-mock:1090" OCEANIC_TRACKS_FUNCTION_SECRET="very-secret-token" DISCORD_GATEWAY_ENABLED="false" diff --git a/AGENTS.md b/AGENTS.md index 75596a1e..302141cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,8 +14,8 @@ Anything about the product or business belongs in the knowledge base; this file - Run locally: `cp .env.dist .env`, then `docker compose up -d --build`. Packages, database schema and seed data are set up automatically. API docs are at . - Test: `docker compose exec app npm run format`, then `npm run lint` and `npm run test:functional` (cucumber-js, `features/`) the same way. These are the steps CI runs. -- Deploy: bump `package.json` `version` in every PR. Merging to `main` tags the version, pushes the image to GitHub Container Registry and deploys it to DigitalOcean. -- Links in Discord messages and emails are `FRONTEND_BASE_URL` plus a path from the web app's route table (`app/routes.ts` in `mypreflight/app`). In production the variable lives only in the DigitalOcean app spec, and nothing here fails when the app renames a route, so check both when a link 404s. +- Deploy: bump `package.json` `version` in every PR. Merging to `main` tags the version, pushes the image to GitHub Container Registry and deploys it to ECS on AWS through the OpenTofu stack in `infra/`. +- Links in Discord messages and emails are `FRONTEND_BASE_URL` plus a path from the web app's route table (`app/routes.ts` in `mypreflight/app`). In production the variable is set in the task definition (`infra/locals.tf`), and nothing here fails when the app renames a route, so check both when a link 404s. When your change alters product behaviour, also open a PR to the knowledge base updating the matching file. When it ships something a pilot will notice, also open a PR to [mypreflight/landing](https://github.com/mypreflight/landing) adding a changelog entry with before and after screenshots; that repo's `AGENTS.md`, under "Changelog", says how. @@ -80,13 +80,13 @@ Endpoints are **one controller class per endpoint** ("actions") under `infra/htt **Error handling:** every error surfaces through one global filter — `DomainExceptionFilter` in `src/core/errors/domain-exception.filter.ts`, registered in `main.ts` via `configureExceptionHandling(app)`. Throw typed domain errors that extend the category classes in `src/core/errors/domain-error.ts` (`BadRequestError`→400, `UnauthorizedError`→401, `ForbiddenError`→403, `NotFoundError`→404, `ConflictError`→409, `UnprocessableError`→422); define the concrete errors under each module's `model/error/*.error.ts` (e.g. `class FlightNotFoundError extends NotFoundError`). The filter maps the error's category to the HTTP status and emits one canonical body — `{ statusCode, error, message, violations? }` — where `statusCode` always equals the HTTP status and `error` is the reason phrase. Do **not** throw `@nestjs/common` HttpExceptions directly from domain, application, or infra code — every error is a typed class extending a `DomainError` category (defined per module in `model/error/*.error.ts`, or alongside a provider client). The only intentional framework-level exceptions left are the JWT/roles **guards** (their bare `401`/`403`) and the global **validation pipe** (`400` with a `violations` map); leave those as-is. The old `{ statusCode, error, message }` constant objects (`errors.dto.ts`) have been removed — never reintroduce that pattern. Note the canonical reason phrase for `422` is `"Unprocessable Content"` (from the filter), not Nest's default `"Unprocessable Entity"`. -**External providers are mocked in dev:** `ADSB_API_HOST`, `SKYLINK_API_HOST`, `SIMBRIEF_API_HOST`, `WEATHER_API_HOST`, `GOOGLE_JWKS_URI` point at the single `mock` container in `compose.yaml` (Mockoon) through network aliases such as `adsb-mock`. Each environment file in `docker/mock/` runs on its own port (`1080`–`1090`), with larger response bodies in a folder named after it. The provider **functions** — `AEROLOPA_FUNCTION_BASE_URL`, `OSM_FUNCTION_BASE_URL`, `POSTCARD_FUNCTION_BASE_URL` and `OCEANIC_TRACKS_FUNCTION_BASE_URL` — use the `functions-mock` alias on ports `1087`, `1088`, `1089` and `1090`. Their function routes require the `X-Require-Whisk-Auth` secret and answer `401` without it, the way the platform does. `prisma/seed/resource/cabin-layout-versions.seed.ts` reads `docker/mock/functions/aerolopa.json` and its `aerolopa/` body files directly, so moving them breaks the seed. The web app's Vite dev server proxies postcard art to `localhost:1089`. Discord is split in two: `DISCORD_API_HOST` points the REST/OAuth client (`DiscordIdentityClient`) at `discord-mock`, while the bot **gateway** (`DiscordGateway`, discord.js) has no mock — it connects under `NODE_ENV=production` or `DISCORD_GATEWAY_ENABLED="true"` and refuses otherwise. Message delivery is gated on `NODE_ENV` alone, so outside production `TestDiscordClient` always writes each message to `test-data/discord/_.md`; `findMembership` answers `unknown` only while the gateway is off (enabling it makes the seeded membership scenarios report `not_member`, so keep the flag off when running the functional suite) — see **docs/DISCORD.md**. +**External providers are mocked in dev:** `ADSB_API_HOST`, `SKYLINK_API_HOST`, `SIMBRIEF_API_HOST`, `WEATHER_API_HOST`, `GOOGLE_JWKS_URI` point at the single `mock` container in `compose.yaml` (Mockoon) through network aliases such as `adsb-mock`. Each environment file in `docker/mock/` runs on its own port (`1080`–`1090`), with larger response bodies in a folder named after it. The provider **functions** — `AEROLOPA_FUNCTION_BASE_URL`, `OSM_FUNCTION_BASE_URL`, `POSTCARD_FUNCTION_BASE_URL` and `OCEANIC_TRACKS_FUNCTION_BASE_URL` — use the `functions-mock` alias on ports `1087`, `1088`, `1089` and `1090`. Their function routes require `Authorization: Bearer ` and answer `401` without it, the way the functions do. `prisma/seed/resource/cabin-layout-versions.seed.ts` reads `docker/mock/functions/aerolopa.json` and its `aerolopa/` body files directly, so moving them breaks the seed. The web app's Vite dev server proxies postcard art to `localhost:1089`. Discord is split in two: `DISCORD_API_HOST` points the REST/OAuth client (`DiscordIdentityClient`) at `discord-mock`, while the bot **gateway** (`DiscordGateway`, discord.js) has no mock — it connects under `NODE_ENV=production` or `DISCORD_GATEWAY_ENABLED="true"` and refuses otherwise. Message delivery is gated on `NODE_ENV` alone, so outside production `TestDiscordClient` always writes each message to `test-data/discord/_.md`; `findMembership` answers `unknown` only while the gateway is off (enabling it makes the seeded membership scenarios report `not_member`, so keep the flag off when running the functional suite) — see **docs/DISCORD.md**. -**AeroLOPA (`src/core/provider/aerolopa/`):** seat maps come from a DigitalOcean Function in the sibling `mypreflight-aerolopa-provider` repo — the API side is an HTTP client and nothing else (no persistence, no scraping). One `GET` carries `op=seatmap|resolve|configurations` plus an `X-Require-Whisk-Auth` secret; `AerolopaClient` exposes `getSeatMap(slug)`, `resolve(airline, aircraft, includeSeatMaps?)` and `listConfigurations()`. `type/aerolopa.types.ts` is **hand-written** and mirrors the vendored `aerolopa.openapi.json` by hand — there is no codegen and no CI drift gate, so when the contract changes, update both (keep the types comment-free; the descriptive prose belongs in the contract). Upstream error codes map to typed errors in `error/aerolopa.error.ts`: `SEAT_MAP_NOT_FOUND`/`SEAT_MAP_UNREADABLE` only on calls that carry a slug, everything else (rejected secret, timeout, upstream 5xx) collapses into `AerolopaUnavailableError`. AeroLOPA is keyed by airline + cabin configuration with **no registration index**, so `resolve()` is often `ambiguous` — the plan is a curated cabin-configuration field on the aircraft rather than a per-request lookup. +**AeroLOPA (`src/core/provider/aerolopa/`):** seat maps come from an AWS Lambda function in the sibling `mypreflight-aerolopa-provider` repo — the API side is an HTTP client and nothing else (no persistence, no scraping). One `GET` carries `op=seatmap|resolve|configurations` plus the function secret as `Authorization: Bearer`; `AerolopaClient` exposes `getSeatMap(slug)`, `resolve(airline, aircraft, includeSeatMaps?)` and `listConfigurations()`. `type/aerolopa.types.ts` is **hand-written** and mirrors the vendored `aerolopa.openapi.json` by hand — there is no codegen and no CI drift gate, so when the contract changes, update both (keep the types comment-free; the descriptive prose belongs in the contract). Upstream error codes map to typed errors in `error/aerolopa.error.ts`: `SEAT_MAP_NOT_FOUND`/`SEAT_MAP_UNREADABLE` only on calls that carry a slug, everything else (rejected secret, timeout, upstream 5xx) collapses into `AerolopaUnavailableError`. AeroLOPA is keyed by airline + cabin configuration with **no registration index**, so `resolve()` is often `ambiguous` — the plan is a curated cabin-configuration field on the aircraft rather than a per-request lookup. -**OpenStreetMap (`src/core/provider/osm/`):** airport infrastructure — boundary, runways, terminals, parking positions and gates — comes from a DigitalOcean Function in the sibling `osm-provider` repo, and the API side is an HTTP client and nothing else. `OsmClient.pullAirport(icao)` is a read: the function holds no credentials for this API and writes nothing. `type/osm.types.ts` is **hand-written** and mirrors the vendored `osm.openapi.json` by hand, exactly as the AeroLOPA types do — no codegen, no drift gate, so update both together. `AERODROME_NOT_FOUND` and `RESULT_TOO_LARGE` map to typed errors; everything else (rejected secret, timeout, Overpass down, a bare `404` from something that is not the function) collapses into `OsmProviderUnavailableError`. +**OpenStreetMap (`src/core/provider/osm/`):** airport infrastructure — boundary, runways, terminals, parking positions and gates — comes from an AWS Lambda function in the sibling `osm-provider` repo, and the API side is an HTTP client and nothing else. `OsmClient.pullAirport(icao)` is a read: the function holds no credentials for this API and writes nothing. `type/osm.types.ts` is **hand-written** and mirrors the vendored `osm.openapi.json` by hand, exactly as the AeroLOPA types do — no codegen, no drift gate, so update both together. `AERODROME_NOT_FOUND` and `RESULT_TOO_LARGE` map to typed errors; everything else (rejected secret, timeout, Overpass down, a bare `404` from something that is not the function) collapses into `OsmProviderUnavailableError`. -**Oceanic tracks (`src/core/provider/oceanic-tracks/`):** today's organised oceanic tracks come from a DigitalOcean Function in the sibling `oceanic-tracks-provider` repo, which holds the FAA NOTAM service key, reads the track messages and places every fix. The API side is an HTTP client and nothing else. `OceanicTracksClient.listTracks()` takes no arguments. `type/oceanic-tracks.types.ts` is **hand-written** and mirrors the vendored `oceanic-tracks.openapi.json` by hand, exactly as the AeroLOPA types do, so update both together. Every failure (rejected secret, timeout, the FAA down or refusing) collapses into `OceanicTracksUnavailableError`. The `oceanic-tracks` module serves the tracks at `GET api/v1/oceanic-track` without sign-in and keeps the function's raw answer in the global cache for 15 minutes (`CACHE_KEYS.OCEANIC_TRACKS`), mapping it on every read. That cache is what keeps the FAA's limit of one data pull every 3 minutes however many people open the map, so don't shorten it or add a second caller. +**Oceanic tracks (`src/core/provider/oceanic-tracks/`):** today's organised oceanic tracks come from an AWS Lambda function in the sibling `oceanic-tracks-provider` repo, which holds the FAA NOTAM service key, reads the track messages and places every fix. The API side is an HTTP client and nothing else. `OceanicTracksClient.listTracks()` takes no arguments. `type/oceanic-tracks.types.ts` is **hand-written** and mirrors the vendored `oceanic-tracks.openapi.json` by hand, exactly as the AeroLOPA types do, so update both together. Every failure (rejected secret, timeout, the FAA down or refusing) collapses into `OceanicTracksUnavailableError`. The `oceanic-tracks` module serves the tracks at `GET api/v1/oceanic-track` without sign-in and keeps the function's raw answer in the global cache for 15 minutes (`CACHE_KEYS.OCEANIC_TRACKS`), mapping it on every read. That cache is what keeps the FAA's limit of one data pull every 3 minutes however many people open the map, so don't shorten it or add a second caller. **The OpenStreetMap data upgrade (`airports` module, `airport/:id/enrich`):** an operations user `GET`s `enrich`, which reads OpenStreetMap, compares it against the airport and answers a proposal — one selectable change per record, each with a stable `: