Skip to content
Merged
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
8 changes: 4 additions & 4 deletions .env.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
12 changes: 6 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <http://localhost/api>.
- 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.

Expand Down Expand Up @@ -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/<type>_<flightId>.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 <secret>` 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/<type>_<flightId>.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 `<resource>:<label>` key, a status of `added`/`removed`/`updated`/`not-changed`, the fields that would be written with current beside proposed, and the `requires` a dependency needs. Nothing is written. The pull is retained in the global cache for an hour (`airportOsmPullCacheKey`), so a repeated review costs Overpass nothing; `?refresh=true` insists on fresh data. `POST`ing the same path with the keys the reviewer accepted applies exactly those: it rebuilds the proposal from the retained pull rather than trusting the request, so what lands is what was reviewed, and it answers `409` when no pull is held. Pushing a `removed` key **deletes** that record — which is why nothing is ever applied unselected.

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ openssl ec -in private.key -pubout -out public.key
This project uses [semantic versioning](https://semver.org/spec/v2.0.0.html).

This project has configured continuous integration and continuous deployment pipelines. It uses GitHub Actions to
automatically build, test and deploy the app to the DigitalOcean. You can find the configuration in `.github/workflows`
automatically build, test and deploy the app to AWS. You can find the configuration in `.github/workflows`
directory.

## Contact
Expand Down
Loading
Loading