# api/

Fastify server run directly on Bun (no build step — Bun executes the TypeScript as-is).
Deployed as a long-running process on a VPS via systemd (see `.github/workflows/`), **not**
serverless. That's a load-bearing fact for how background work is structured below.

Handles everything for DeFlock that isn't OpenStreetMap map data: geocoding proxy
(Nominatim), GitHub sponsors, and the contact form.

## Conventions

- No ORM/DB. Every external service gets its own thin client class in `services/` that talks
  to that service's HTTP API directly via native `fetch` (or, where the service already has an
  official SDK in use elsewhere in this org, that SDK — e.g. `AiScreeningClient` uses the
  `openai` package). See `ZammadClient`, `NominatimClient`, `GithubClient`, `TurnstileClient`.
- Config/data that isn't a secret is checked into git and read at startup, not hardcoded and
  not fetched at runtime — e.g. `data/zipcodes-us.json`, `prompts/contact-screening.md`, and
  `kb/*.md`. Editing these requires a server restart to take effect (loaded once at module
  init).
- OpenTelemetry (`telemetry.ts`) exports logs/metrics to Grafana Cloud via a local otelcol
  sidecar. Errors are bucketed by a substring-matching `classifyErrorMessage()` in `server.ts`
  — new upstream integrations should extend that rather than inventing a separate error-typing
  scheme.
- Tests use `bun:test`. Prefer dependency injection (pass a stub client/SDK instance into the
  constructor or function) over mocking `global.fetch` when the thing being tested isn't
  itself an HTTP client — see `ContactScreeningService.test.ts` vs `ZammadClient.test.ts`.

## Contact form + AI screening

**Intent**: the volunteer support team gets more contact-form messages than they can
individually triage. Every submission that passes Cloudflare Turnstile gets a first pass from
an AI classifier before a human sees it — unless the sender explicitly opts out via a
checkbox on the form. The AI never sends anything to a customer directly; it only drafts,
tags, and prioritizes for a human to review in Zammad.

**Ordering matters**: the Zammad ticket is always created synchronously and the user gets
their success response immediately. AI screening then runs as fire-and-forget background work
*after* the response is sent — it relies on this process staying alive to finish, which is
only safe because this is a persistent server, not a serverless function. A screening failure
(OpenAI down, bad response, Zammad write failure) never blocks ticket creation or crashes the
process — it just gets tagged/logged and a human handles that ticket without AI assistance,
same as before this feature existed.

**Where the classification logic lives**: the taxonomy, tone, and per-category instructions
are entirely in `prompts/contact-screening.md` (checked into git, not in TypeScript) so
non-engineers can review/iterate on classifier behavior like any other reviewed change. Code
(`ContactScreeningService.ts`) only translates the AI's structured output into Zammad actions
(tags, priority, group, shared draft vs. internal note, scheduled close) — it deliberately
does *not* contain classification judgment calls itself, except for hard safety backstops
(e.g. force-tagging) that shouldn't depend on the model remembering a rule. When product
behavior changes (new category, new disposition), expect to change the prompt first and the
plan/orchestration code second — they're meant to stay in sync but are two different kinds of
change (policy vs. mechanism).

**Dry-run endpoint**: `POST /contact/message/dry-run` runs the same classifier + planning
logic with no Turnstile check and no Zammad writes, for testing prompt/taxonomy changes
quickly. It has no auth — fine for local dev, but don't expose it publicly without adding
some, since it's a free OpenAI-call relay otherwise.
