# New knowledge base

## 2026-10 — Channel auto-sync to Odoo / Trello / Jira / GitHub

- Channel detail **Ticket sync** dropdown picks one enabled provider. Empty = off.
- On the **first message** of a new ticket (`messages.sequence = 1`), the API creates the remote ticket using the compact preview payload. Unique `(ticket, provider)` prevents duplicates.
- Web chat inherits this from the primary WhatsApp channel settings (same as AI/onboarding).

## 2026-10 — Ticket integrations (Odoo / Trello / Jira / GitHub)

- Admin configures providers under **Settings → Ticket integrations** (dropdown switches Odoo / Trello / Atlassian / GitHub). Secrets are masked after save; blank secret fields keep the existing value.
- Sync is **manual**: ticket detail shows **Sync this to {Provider}** only when that provider is enabled. Preview loads a compact title + description (contact, priority, first/latest inbound snippets — not the full thread). Staff can edit fields, then confirm.
- One link per ticket per provider (`ticket_integration_links`). Re-sync returns 409; UI then offers **Open in …**.
- Odoo uses JSON-RPC (`helpdesk.ticket` by default). Trello creates a card on `list_id`. Atlassian creates a Jira issue (ADF description). GitHub creates a repo issue.
- API: `GET/PUT /api/v1/integrations`, `GET /enabled`, `GET …/tickets/:id/preview|:id/sync|:id/links`. Migration `029_ticket_integrations.sql`.

## 2026-10 — Calendar day booking + WhatsApp confirm

- Default **month** view; click a day (or month slot) → **day** view. Click empty time → create booked appointment (contact/phone, WhatsApp channel, assignee, call-out/meetup).
- Buffers labelled **In · Travel/Prep** and **Out · Travel/Prep**.
- Booking drawer: pick channel → **Send & wait** WhatsApp YES/NO; mark client confirmed/declined. State lives in `calendar_requests.extraction.client_confirm` (no new enum). Staff-created bookings use `POST /api/v1/calendar/requests` and open/reuse a direct ticket for the contact.

## 2026-10 — Manage → Staff directory

- Sidebar **Manage → Staff** (`/staff`) lists active `admin`/`user` accounts (not portal users). Distinct from **Admin → Users** (CRUD). Card / list toggle (persisted in `localStorage`).
- Staff detail is **full-screen** in the main column: WhatsApp chat is the primary pane; tickets / calendar / audit sit in a side rail.
- Chat is keyed to the staff profile **WhatsApp phone** (`users.phone` → `contacts`). `POST /api/v1/users/staff/:id/chat` reuses the open direct ticket for that contact so inbound replies from that number stay in the same history. `GET …/staff/:id` includes `chat.messages` (merged direct-thread history).

## 2026-10 — Dashboard nav: channels, KB, KPIs, today calendar

- **Channels** is no longer in the sidebar; open **Settings → Manage channels** (`/channels` unchanged).
- **Knowledge base** sits in **Admin** and is `RequireAdmin` (non-admin staff are redirected home).
- Dashboard **Open tickets** KPI navigates to `/tickets?status=open`.
- Admin dashboard top KPIs include **Active users** (`is_active` staff/admin count) with subtitle **currently up**.
- Staff dashboard **Today's calendar** lists booked `calendar/events` for the local day (max 6) and links to `/calendar`.

## 2026-08 — Admin dashboard staff workload

- **Active users block (admin-only):** Dashboard fetches `GET /api/v1/users` only when `user.role === 'admin'` (endpoint is admin-guarded). Non-admin staff and portal users never see this card.
- **Active staff** = `is_active` + role `admin` | `user` (excludes `company_user`).
- **Open counts:** Client aggregates `GET /tickets?status=open&limit=100` by `assigned_to`; every active staff row shown (including 0 open); footer shows unassigned open count. Counts approximate when >100 open tickets.
- **Live feed enrichments (all dashboard viewers):** Status badge, assignee (`assigned_user.display_name` → email → “Unassigned”), relative time from `last_message_at ?? updated_at ?? created_at`.
- **Ticket API:** `ticketService` list/getById return `assigned_user.display_name` and `last_message_at` (`MAX(messages.created_at)` subquery). Shared `Ticket` type updated in `@wa-ticketing/shared`.

## 2026-08 — Group vs direct ticket routing

- `findOpenTicket(contact, channel)` only returns **direct** open tickets (`wa_group_jid` null/empty). Prevents 1:1 DMs from attaching to an open group ticket and AI replies from posting into the group.
- Group inbound still uses `findOpenByGroupJid` only.

## 2026-08 — Group AI replies in-group + LID mentions

- Worker sends `channelMentionIds` (sock.user phone + LID) with group inbound so `@7409…` style tags count as mentioning the channel.
- `sendAutomatedOutbound` delivers WhatsApp replies to `tickets.wa_group_jid` when set (same as staff replies).
- Follow-up rule: if the **latest outbound** on the ticket asks a question (`?` / please describe / …), later inbounds may omit @tag and still get talkback (unless casual chatter). Ends when the channel sends a non-question outbound.

## 2026-08 — WhatsApp group member management

- Ticket detail **Group** right-rail card when `wa_group_jid` is set (`TicketGroupPanel`).
- Live members via worker **`POST /group/metadata`** (Baileys `groupMetadata`); add/remove via **`POST /group/participants`** (`groupParticipantsUpdate`).
- Staff-only API: `GET /tickets/:id/group`, `POST|DELETE /tickets/:id/group/participants` with `{ user_id }`. Client phone cannot be removed; DB `wa_group_participant_user_ids` stays in sync for staff.

## 2026-08 — Staff phone & remote/support tags

- Column **`users.staff_tags`** (`TEXT[]`, migration `025`): values `support` | `remote`.
- Admin create user + user detail can set **phone** and tags. Group ticket modal filters staff by All / Support / Remote (`GET /users/staff-for-groups` returns `staff_tags`).

## 2026-08 — Group talkback only when tagged or asked

- Group inbound messages still land on the ticket always.
- AI talkback runs only if the channel is **@mentioned** (Baileys `mentionedJid`, phone, **LID**, or `@Silicon Support` in text) **or** the message looks like a **support question** (`?` / how/can you/…) **or** the inbound is a **follow-up** to the channel’s last outbound question (no @tag required; casual “lol”/“thanks” still skipped). Casual unrelated chatter → no auto-reply (`groupTalkbackGate.ts`). Replies go to **`wa_group_jid`**.

## 2026-08 — WhatsApp group tickets

- Migration **`024_ticket_wa_group.sql`**: `tickets.wa_group_jid` / `wa_group_subject` / `wa_group_participant_user_ids`; `messages.from_phone` for group senders.
- Worker **`POST /group/create`** → Baileys `groupCreate`; **`/send`** accepts `@g.us` JIDs.
- Inbound with `groupJid` attaches only to an open ticket with matching `wa_group_jid` (never creates a 1:1 ticket from a group).
- Staff create: `POST /tickets` with `mode: "group"`, `staff_user_ids` (1–3), `group_subject`. Staff list: `GET /users/staff-for-groups` (requires phone on user).

## 2026-08 — Staff-initiated WhatsApp tickets

- `POST /api/v1/tickets` (staff): body needs `channel_id`, `content`, and exactly one of `contact_id` | `phone_number`.
- Channel must be WhatsApp + `connected`. Always inserts a **new** ticket (does not reuse open ones), assigns `assigned_to` to the actor, intake `done`.
- First outbound uses the same Baileys send path as ticket replies; send failure still returns 201 and emits `message:sent` with `error`.
- UI: `StaffNewTicketModal` from Layout **New ticket** and Tickets page.

## 2026-08 — Ticket chat customer context rail

- Staff ticket detail loads **`GET /contacts/:id`** in **`TicketCustomerPanel`** for company name, account code, AI autoreply flag, and ticket history.
- **Earlier tickets** accordion shows up to **2** prior tickets (excludes current): channel, status, assignee (“where it moved”), priority. Link to contact for full history when more than 3 tickets exist.
- Priority edit lives under **Ticket details** (stacked with status/channel/opened), not a separate actions block.

## 2026-08 — Calendar call-outs & meetups

- Table **`calendar_requests`** (migration `023`) stores LLM-suggested then staff-booked appointments linked to ticket/message/channel/contact.
- **Blocked window** for conflicts: `starts_at − buffer_before` through `ends_at + buffer_after`.
  - **Call-out:** 60m appointment + **30m travel** before and after.
  - **Teams / online:** **45m** meeting + **0 travel** + **15m prep** after (1 hour total blocked).
- Detection runs **inside talkback** (before AI) so booking voice/text messages get a calendar acknowledgment instead of KB deflection. Relative times use **Africa/Johannesburg**. Ambiguous “booking” asks the customer: **call-out** vs **Teams**.
- Suggestions are **never auto-booked**. Staff confirm under **Manage → Calendar**. Socket event: `calendar:updated`.
- `meetup` in the UI means **Teams / online**; `call_out` means **site visit**.

## 2026-08 — Ticket realtime rooms + talkback vision

- Socket.IO `message:new` is room-scoped (`ticket:<id>`). Clients must **re-emit `tickets:subscribe` on `connect`**, or chat goes silent after API restart.
- Talkback previously flattened images to `[image] …` text. OpenAI agents now get `data:` image URLs via `ticketImageAsDataUrl` (cap `VISION_INLINE_MAX_BYTES`, default 4MB). Anthropic path still flattens to text until vision parts are added.
- Voice notes are transcribed with **Whisper** (`WHISPER_MODEL`, default `whisper-1`) using platform OpenAI credentials. Transcript replaces `[Voice note]` on the message row and is passed to talkback as user text.

## 2026-08 — Ticket media needs ANON/SERVICE JWTs signed with JWT_SECRET

- Inbound WhatsApp images call Storage via `SERVICE_ROLE_KEY`. If that JWT was **not** signed with `JWT_SECRET`, Storage returns **`invalid signature`** and the API responds **503** — the ticket never gets `media_signed_url`.
- `ANON_KEY` / `SERVICE_ROLE_KEY` must be HS256 JWTs with `role` `anon` / `service_role`, signed with the same `JWT_SECRET` used by Storage (`PGRST_JWT_SECRET`).
- Browser URLs use **`SUPABASE_PUBLIC_URL`** (must match published Kong port, e.g. `http://localhost:8093`).

## 2026-08 — Outbound voice notes: UI feedback + WhatsApp ogg conversion

- Staff ticket composer showed no progress after stopping a recording; message only appeared after a slow refetch. Fix: merge POST `/messages` response into the ticket cache immediately, show **Recording…** / **Sending voice note…**, surface mic permission errors.
- Browser `MediaRecorder` emits **webm**; WhatsApp PTT expects **ogg/opus**. WhatsApp worker now runs **ffmpeg** webm→ogg before `ptt: true` send.

## 2026-08 — Channel reconnect must wipe Baileys auth

- Baileys creds live under **`/tmp/wa_ticketing_auth/<channelId>`** (Docker volume **`wa_auth`**). On disconnect/logout they often remain.
- Reconnect that only `DELETE /sessions/:id` + `POST /sessions` reloads those creds → Baileys **logs in** (no QR) → WhatsApp returns **Logged out** → UI stuck on “Generating QR code…”.
- Fix: `POST /sessions` accepts **`clearAuth: true`** (used by `POST /api/v1/channels/:id/reconnect`). Worker also wipes auth when Baileys reports `DisconnectReason.loggedOut`.

## 2026-07 — User automation system-test harness

- Package **`user_automation/`** drives the ticketing API (not the browser). Personas map to existing roles only: `admin_operator`→`admin`, `agent_handler`→`user`, `end_user`/`technical_user`→`company_user`; `email_reporter` / `landing_visitor` need no JWT.
- **Hybrid bootstrap:** manifest lists fixed staff + portal users; staff scenarios also `POST /companies`, `POST /companies/:id/users`, and (admin) `POST /users`. Created accounts are logged in cycle reports but not auto-joined into the tick loop.
- **Email inbound token** is stripped from `GET /channels` list (`null`); use env `SIM_EMAIL_INBOUND_TOKEN` or `GET /channels/:id` detail (admin discover scenario).
- **Landing chat** captcha only applies when `TURNSTILE_SECRET_KEY` is set; sim soft-skips captcha failures.
- Default run is **`--cycles 1`** with reports under **`user_automation/reports/`**; `--cycles 0` is infinite soak. Failures are continue-on-error.

## 2026-04 — Docker: committed `docker/supabase/` vs gitignored `volumes/`

- The repo **`.gitignore` previously ignored all of `volumes/`**, so clones had **no** `volumes/db/*.sql` or `kong.yml`. Docker Desktop then often created **empty directories** for missing bind-mount sources (`kong.yml: Is a directory`), and Postgres **never** created `_supabase` → Logflare failed with `database "_supabase" does not exist`.
- **Fix:** init SQL, Kong template + `kong-entrypoint.sh`, `vector.yml`, and Edge `functions/main` now live under **`docker/supabase/`** (tracked). Only **`volumes/storage/**`** (uploads) stays ignored. After upgrading, run **`docker compose down -v`** once if you need a clean `pgdata` re-init.
- **`docker compose down` (no `-v`)** keeps the **`pgdata`** named volume, so Postgres **does not** re-run `/docker-entrypoint-initdb.d` scripts. If the data directory was created without `_supabase.sql` / `roles.sql`, Logflare still sees **`database "_supabase" does not exist`** until you **`docker compose down -v`** and bring the stack up again (dev-only data loss).

## 2026-04 — Kong declarative plugins (`request-termination`, `post-function`)

- **`kong:2.8.1`** only loads plugins listed in **`KONG_PLUGINS`**. Upstream **`docker/supabase/kong.yml`** references **`post-function`** and **`request-termination`**; if they are omitted from **`KONG_PLUGINS`**, Kong fails at startup with *plugin … not enabled*.
- **Frontend Docker on Windows:** CRLF in **`docker-entrypoint.sh`** yields **`exec /docker-entrypoint.sh: no such file or directory`** (kernel shebang path includes `\r`). The frontend **Dockerfile** runs **`sed -i 's/\r$//'`** on copy so the image stays valid even when the repo is checked out with CRLF.

## 2026-04 — Postgres `POSTGRES_PASSWORD` vs existing volume

- Service roles (`supabase_auth_admin`, `authenticator`, `supabase_storage_admin`, …) get their passwords at **first** `db` init (see `docker/supabase/db/roles.sql`). If `POSTGRES_PASSWORD` in `.env` later **differs** from what was used at init, containers still read the new value from env but Postgres still has the old passwords → `FATAL: password authentication failed` / SQLSTATE `28P01`.
- **`npm run sync:db-passwords`** runs `ALTER ROLE … WITH PASSWORD` for the standard roles to match `.env`. Rebuild/restart auth and other Supabase services after.

## 2026-04 — Frontend runtime API URL (Docker)

- **`apps/frontend/docker-entrypoint.sh`** writes **`/runtime-config.js`** (`window.__RUNTIME_CONFIG__`) from **`PUBLIC_SITE_HOST`**, **`API_HOST_PORT`**, **`PUBLIC_HTTP_SCHEME`**. **`getApiBaseUrl()` / `getWsUrl()`** in `src/lib/runtimeConfig.ts` read that first, then **`VITE_*`** for `npm run dev`. Change LAN IP/port without rebuilding the Vite bundle — `docker compose up -d --force-recreate frontend` after `.env` edits.

## 2026-04 — Docker host ports (multi-stack)

- Published ports in `docker-compose.yml` use `.env` variables: `STUDIO_HOST_PORT`, `KONG_HTTP_HOST_PORT`, `KONG_HTTPS_HOST_PORT`, `API_HOST_PORT`, `POSTGRES_HOST_PORT`, `POOLER_HOST_PORT`, `ANALYTICS_HOST_PORT`, plus `FRONTEND_PORT`.
- **`PUBLIC_SITE_HOST`** (default `localhost`) plus `_*_HOST_PORT` drives browser-facing URLs in compose: `VITE_*`, `SUPABASE_PUBLIC_URL`, `API_EXTERNAL_URL`, `SITE_URL`/`FRONTEND_URL`, and API `CORS_ORIGIN`—override full URLs only for https or proxies.

## 2026-04 — Company-linked knowledge modules (AI guardrails)

- Markdown lives in **`knowledge_modules`**; **`company_knowledge_modules`** toggles which modules apply per company. **`companies.ai_autoreply_enabled`** gates whether talkback may call OpenAI for contacts linked to that company.
- **`talkbackService`** loads `contacts.company_id`, requires AI on the channel, company autoreply on, and at least one enabled module; it concatenates module bodies (ordered by `sort_order`) after the channel’s `knowledgeBaseText` slice and adds a refusal-style system instruction. No embeddings/RAG in this path — total injected text is capped similarly to the previous single `KB_MAX` budget.

## 2026-04 — Per-company AI agents (multi-provider)

- **`ai_agents`** holds `display_name`, `provider` (`openai` | `anthropic` | `openai_compatible`), `api_key`, optional `base_url`, `model_id`, `persona_system`, `guardrails` (optional extra instructions). **`ai_agent_knowledge_modules`** links agents to catalog **`knowledge_modules`** for scoped guardrails (`017_ai_agent_knowledge_modules.sql`). Empty selection = use all modules the company has enabled; non-empty = intersection with **`company_knowledge_modules`** at talkback time.
- **`company_ai_agents`** is one row per company with the chosen default agent (`016_ai_agents.sql`).
- **`llmProviderService.completeChat`** routes OpenAI-compatible JSON chat, Anthropic Messages API, and custom base URLs. **`aiAgentService`** masks keys on read; **`POST /api/v1/ai-agents/preview-models`** accepts credentials in the body for model listing.
- Talkback: if the company’s default agent exists and has a key, use **`completeChat`** with persona + guardrails merged ahead of the existing KB stack; otherwise fall back to **`chatCompletionText`** with channel settings + **`resolveOpenAiCredentials`** (platform DB/env).
- **Unit tests** for Anthropic message shaping live in **`anthropicMessageHelpers.test.ts`** so imports do not pull DB bootstrap (`DATABASE_URL`).

## 2026-07 — Landing page chat (ticket only, company match)

- **`landing_sessions`** (migration `022` + API `CREATE TABLE IF NOT EXISTS`) stores mode (`welcome` / `ask_name` / `ask_company` / `ask_email` / `support`) and state (`display_name`, `company_id`, `email`, `ticket_id`, `verified`). **No passwords** in this flow.
- The table is owned by the `supabase_admin` migration role while the API connects as `postgres`. Migration `026` grants the API `SELECT` / `INSERT` / `UPDATE`; owner-only index creation must not run in the request path.
- Gate: company must resolve via `companyService.resolveForJoin` (name or account code); email must match an active `company_user` for that company (or an `email:` contact on that company). Else → **Request access**.
- After verify: guest contact `portal:guest:{sessionId}` linked to company; ticket created with intake already `done`; talkback awaited synchronously for AI replies.
- Console JWT login stays on the right-hand form only. Signup stays in **Request access** / `signupService`.

## 2026-04 — Login signup chat

- **Public signup** uses `signup_sessions` (state machine over `POST /api/v1/signup/chat`), SHA-256–hashed verification tokens, and `POST /api/v1/signup/complete` to create `company_user` rows after the user sets a password.
- **`companies.account_code`** is a stable customer-facing join key (backfilled from existing `id`); new companies get a random `C` + hex code from `companyService.upsertByName` insert path.
- **`FRONTEND_URL`** on the API builds verification links; compose defaults to `http://PUBLIC_SITE_HOST:FRONTEND_PORT` unless overridden.

## 2026-04 — WhatsApp ticket media (Supabase Storage)

- **`018_message_media.sql`** adds `message_type` (`text` | `image` | `voice_note`) and media columns on **`messages`**. The API creates bucket **`ticket-media`** (via `supabase-js` `createBucket`) on first upload if missing.
- **Inbound:** Baileys **`downloadMediaMessage(msg, 'buffer', {}, ctx)`** with **`reuploadRequest: sock.updateMediaMessage`**; payload to **`POST /internal/messages`** uses base64 (large body; route **`bodyLimit`** ~58MB).
- **Outbound:** Staff **`multipart/form-data`** on **`POST /api/v1/tickets/:id/messages`** with `kind` + `file`; worker **`POST /send`** accepts **`kind: image | voice_note`** and Baileys **`sendMessage`** with **`image`** or **`audio` + `ptt: true`**.
- **Signed URLs:** `createSignedUrl` uses internal **`SUPABASE_URL`**; **`toBrowserSignedUrl`** rewrites links to **`SUPABASE_PUBLIC_URL`** before the API returns them to the UI (fixes `kong:8000` / `ERR_NAME_NOT_RESOLVED` in the browser). **`enrichMessageWithMediaUrl`** swallows errors so tickets still load if Storage is misconfigured.
- **Audit (2026-06):** Full flow trace, Trello checklist, and gaps — **`docs/whatsapp_media_audit.md`**. **Ticket detail** listens to **`message:sent`** and marks failed outbound bubbles (`apps/frontend/src/lib/ticketMessageDelivery.ts`); disconnected WA channels block the composer with a banner.
- **Documents (2026-06):** `message_type` **`document`** (`020_message_document_type.sql`). Baileys **`documentMessage`** → `/internal/messages`; worker **`kind: document`**; thread UI file button + download. **Preview:** PDF/plain text in **`TicketMediaPreviewDialog`** (`mediaPreview.ts`); Office/zip download-only. **Trello media audit closed** — see **`docs/whatsapp_media_audit.md`** (verification checklist before marking Done).

## 2026-09 — Africa's Talking WhatsApp provider

- **`channels.provider`** (`whatsapp_provider` enum: `baileys` | `africas_talking`, migration `028_channel_whatsapp_provider.sql`) is required for `kind = 'whatsapp'` and null otherwise; existing WhatsApp rows are backfilled to `baileys`. Capabilities (`direct_text`, `media`, `groups`, `qr_pairing`, `session_reconnect`, `templates`) are computed server-side and returned on channels; the UI only renders them (`ChannelCapabilities`, `ChannelProviderBadge`) and never infers support itself.
- **Credentials** are one deployment-wide pair (`AFRICASTALKING_USERNAME`, `AFRICASTALKING_API_KEY`) read by the API from env; nothing per channel is stored. `planChannelCreate` validates that **both** are non-blank (whitespace-only counts as blank) and refuses to create an Africa's Talking channel otherwise; creation only checks presence, it does not call Africa's Talking. Provider errors (`WhatsAppProviderError`) carry a fixed message/type/status only — never the key, headers, or provider bodies.
- **Outbound media** must be a hosted URL: the provider throws `media_url_required` (400) without one, so the API sends the Supabase signed URL. Templates are flagged as a capability but there is no admin UI for them.
  - **Deployment trap:** Africa's Talking fetches that URL from **their** infrastructure, so `SUPABASE_PUBLIC_URL` must be a publicly reachable **HTTPS** origin. Compose derives it from `PUBLIC_SITE_HOST` + `KONG_HTTP_HOST_PORT`, which is fine for the browser but yields `http://localhost:8000`-style values in local and some single-host deployments. Media sends then fail at the provider with nothing wrong in our logs. Baileys is immune — it uploads bytes directly.
- **Send eligibility** (`channelProviderRules.canStaffSendOnChannel`, a type guard so it also narrows `channel` for callers): connected channels always send; additionally an Africa's Talking **WhatsApp** channel at `disconnected` may send. That status is only ever set by an outbound auth failure and the provider has no reconnect, so blocking sends would strand the channel with no operator action that could clear it — the send *is* the revalidation path. `connecting` is **not** included (never established, not a credential failure), and the exemption does not apply to a non-WhatsApp channel that happens to name the provider. All four staff-send guards in `routes/tickets.ts` (new-ticket create, multipart caption, multipart media, JSON text) call this one helper; the frontend mirror is `canSendOnWhatsAppChannel`, which keys on `capabilities.session_reconnect === false` because ticket payloads embed capabilities rather than the provider field.
- **Provider status lifecycle** (`services/providerStatusLifecycle.ts`, `trackOutboundProviderStatus`): the HTTP provider has no session and no connection event, so a dead API key would otherwise leave the channel reading `connected` while every send fails. Both `channelService.sendMessage` and `sendMediaMessage` wrap their adapter call in the same helper — a `WhatsAppProviderError` of type `authentication` sets `disconnected`; the next success from a `disconnected` channel sets `connected`. Deliberate non-behaviours: other error types (timeout/network/rejected/http/config) never move status, Baileys channels are passed straight through so the worker keeps ownership, and the status write is best-effort so it can never mask the original error or fail a send that worked (it self-corrects on the next send).
- **Inbound** is `POST /api/v1/inbound/whatsapp/africas-talking/:token`. The token is a provider-scoped UUID (`channels.inbound_token`), redacted for non-admins on channel create/detail. 1 MiB body limit.
  - **Body formats:** JSON *and* `application/x-www-form-urlencoded`. `registerWebhookFormBodyParser` adds a scoped `addContentTypeParser` built on `URLSearchParams` — no `@fastify/formbody` dependency for a flat key/value decode. Scoped to the route plugin, so sibling routes still answer 415; there is a test asserting that. Note Fastify already ships parsers for JSON **and `text/plain`**, so `text/plain` is not a valid "unsupported type" probe — use `application/xml`.
  - **Non-message callbacks:** `classifyAfricasTalkingCallback` returns a typed `{kind:'message'|'ignored'}` rather than making the caller read an exception's reason string. Delivery/status/read events answer **200 `{ok:true, ignored:true, reason:'unsupported_event'}`**; malformed payloads still throw a 400. A known message type in `type` / `messageType` / `mediaType` (or `body.*`) wins only when the callback has actual message content. That preserves real image/voice callbacks while allowing receipts that echo the original message type but contain no content. So `{status:'Delivered', text:'hi'}` and `{messageType:'image', mediaUrl:'https://…'}` are messages, while `{eventType:'delivery', messageType:'text'}` and `{messageType:'delivery'}` are events; `{type:'sticker'}` remains an unsupported-message 400.
  - **Rate limit: 600/min keyed on the channel token**, not the request IP. The provider egresses from a small shared address pool, so an IP key lets one noisy channel throttle every other tenant. The key is `sha256(token)` — limiter keys surface in store dumps and metrics labels, and this token is the endpoint's only credential. `inboundWhatsappRateLimit` is exported so the key function is unit-testable; a regression to `req.ip` fails the "same token, two IPs" assertion.
- **Inbound media guard** lives in `apps/api/src/services/inboundMediaSecurity.ts` (split out of `africasTalkingWebhook.ts`, which re-exports it so import sites are unchanged). HTTPS only, `redirect: 'error'`, hostname resolved before the fetch, and every resolved address checked. Rejected: private/loopback/link-local; IPv4-mapped IPv6 in both hex `::ffff:hhhh:hhhh` and dotted `::ffff:1.2.3.4` form; IPv4-compatible `::1.2.3.4`. Four prefixes that exist purely to carry an IPv4 destination are refused **whole**, without reading the embedded address: NAT64 `64:ff9b::/32` (the code matches the first two groups, so it covers the well-known `/96` and the local-use `64:ff9b:1::/48`), 6to4 `2002::/16`, Teredo `2001:0::/32`, and IPv4-translated `::ffff:0:0/96`. Teredo is matched as `groups[0] === 0x2001 && groups[1] === 0`, so the rest of `2001::/16` (e.g. `2001:4860::/32`) stays reachable — there is a test pinning that.
- **`downloadWebhookMedia` clamps** to the 16 MiB webhook ceiling with `Math.min`, so a caller can tighten the cap but never raise it toward the larger internal `MEDIA_MAX_BYTES`.
- **Media fetch backpressure:** `createMediaFetchLimiter(capacity, queueLimit, queueWaitMs)` — `WEBHOOK_MEDIA_CONCURRENCY` (4) slots, `WEBHOOK_MEDIA_QUEUE_LIMIT` (= concurrency, so one further round) and `WEBHOOK_MEDIA_QUEUE_WAIT_MS` (5000). Over the queue bound, or past the deadline while waiting, the callback is shed with reason `media_busy` → **HTTP 503** plus `Retry-After: 5` (`webhookRetryAfterSeconds`, wired explicitly in `routes/inboundWhatsapp.ts`).
  - **Why the queue is shallow and deadlined, not deep:** a callback parked behind a long queue can outlive the provider's own request timeout. The provider then redelivers, and the redelivery can clear `isDuplicate` before the first copy has finished downloading and persisting. **Duplicate storage is not the risk** — `messages.wa_message_id` is `UNIQUE` (`005_messages.sql`), so `isDuplicate` is only a fast path in front of the constraint and the loser of that race cannot write a second row. What it actually costs: both copies download the media for nothing, and the losing insert raises a unique violation that surfaces as a transient **500**; the provider retries, the first copy is committed by then, and the callback is finally acknowledged as a duplicate. Shedding early with a 503 the provider is *told* to retry is the cheaper failure.
  - **Slot ownership:** a finishing task hands its slot straight to the next waiter, so the granted count only moves when a slot is created or retired. Each waiter settles once — handoff or deadline, whichever comes first — so a shed waiter never abandons a slot it was just handed and never retires one it never held. `release()` skips already-settled waiters rather than assuming the head of the queue is live.
- **Residual risk — DNS rebinding:** the host is resolved once for the allow/deny decision and again by `fetch` when connecting, so a short-TTL record that changes between the two can still steer the connection to a private address. Closing it needs connect-time IP pinning (custom dispatcher/agent), deliberately out of scope. Blast radius is limited by HTTPS-only, no redirects, the byte cap, and bodies only ever being stored as ticket media, never interpreted.
- **Baileys-only features:** groups, QR pairing, session reconnect. Guards use `assertCapability`; the Channels page shows "review configuration" instead of Reconnect when `session_reconnect === false`.
- **Rollback (not executed):** `tickets.channel_id` has no cascade (blocks deleting a channel) and `calendar_requests.channel_id` cascades (deleting silently drops rows). Safe order, app stopped: back up DB → reassign AT tickets/calendar requests to a retained Baileys channel (or explicitly archive/delete under operator policy) → delete AT channels → drop constraint `channels_whatsapp_provider_required`, column `channels.provider`, type `whatsapp_provider` (in that order) and the `026` row in `_wa_sql_migrations` → deploy old code in the same window. Full SQL with UUID placeholders is in the README.
- **Frontend unit tests exist now:** `apps/frontend` has `test:unit` (`tsx --test src/lib/*.test.ts`), the same `node:test` pattern as the API, covering the pure send-gating helpers in `lib/ticketMessageDelivery.ts`. Keep UI decisions in that module so they stay testable — there is no React test renderer in this repo. `apps/frontend/tsconfig.json` **excludes `src/**/*.test.ts`**: the tests import `node:test`, and pulling `@types/node` into a browser build would swap DOM globals for Node ones. The trade-off is that those test files are type-stripped by `tsx` but never type-checked.
- **Testing the routes:** `test:unit` now globs `src/services/*.test.ts` **and** `src/routes/*.test.ts`. Route tests use `fastify.inject` against the real route plugin; two things make that possible without a harness — a dummy `DATABASE_URL` (the postgres client connects lazily, and stubbing `messagingService.isDuplicate` to `true` short-circuits the handler before any query), and stubbing the object-literal service exports in place. It does need native modules built: an `npm install --ignore-scripts` leaves `bcrypt` without its binding and the route suite dies on import — `npm rebuild bcrypt` fixes it, and the Dockerfile's `npm ci` builds it anyway.
- **Not yet verified:** a real Africa's Talking callback and live credentials have not been exercised; only unit and route-inject tests cover the connector.
