# CLAUDE.md

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

## What this is

Wedding invitation & RSVP automation. Admins create events — one of three ceremony types
(Mehedi, Marriage, Walima) plus a name, any number of them — and each event owns its guest
list and its invitation card. Guests open a tokenized link, see a personal greeting above
the card artwork, accept, and fill a form; the host watches live headcounts and exports a
caterer list; the worker sends reminders at T-15/7/2 days.

Planned across two OpenSpec changes, and **both matter**:

* `openspec/changes/add-rsvp-v1/` — the original system. Decisions D1–D14.
* `openspec/changes/add-event-invitation-card/` — the card, the greeting, the RSVP surface,
  and **event-scoped guests**. Decisions D1–D12, and they supersede parts of the first
  change wherever the two disagree (the superseded requirements are marked in place).

**Read both `design.md` files before making architectural changes** — decisions are numbered
and referenced from code comments (`design D7`, `design D11`, `task 8.1`). `tasks.md` is the
live work tracker; `specs/*/spec.md` hold the per-capability requirements. The PRD
(`rsvp-prd.pdf`) is the upstream source of truth for the first change, and its identifiers
(FR-x.y, §4.2) are cited throughout the code.

## Commands

Everything runs through Docker Compose via the `Makefile` — it is the only entry point
anyone should type. `make` is not on Windows by default (`scoop install make`); run targets
from Git Bash. `make help` lists them all.

```bash
make dev        # start the dev stack (hot reload, DRY_RUN=true) at http://localhost
make migrate    # alembic upgrade head
make seed       # demo wedding, events, templates, first Super Admin (dev only)
make test       # pytest -q inside the api container
make lint       # ruff check + mypy app + web tsc --noEmit
make format     # ruff check --fix + ruff format
make client     # regenerate web/lib/api/schema.d.ts from the API's OpenAPI
make logs S=api # tail one service
```

Most targets take `ENV=prod` to act on production instead (`make migrate ENV=prod`).

A single test (the Makefile has no pass-through argument):

```bash
docker compose -f docker-compose.yml -f docker-compose.dev.yml --env-file .env.dev run --rm api pytest tests/test_reminders.py -k quiet_hours -q
```

Tests split in two: pure unit tests that need nothing, and integration tests in
`test_pipeline_integration.py` that need real Postgres (they verify `FOR UPDATE SKIP LOCKED`
and unique-constraint races, which no fake reproduces). They skip cleanly when
`DATABASE_URL` is unset, so the suite still passes offline.

CI (`.github/workflows/ci.yml`) runs ruff + `ruff format --check` + mypy + alembic + pytest
for the API, typecheck + lint + build for the web app, and a **generated-client freshness
check** that fails if `web/lib/api/schema.d.ts` differs from what the API would emit.

## Architecture

Split by design (D1): `web/` renders, `api/` decides. The browser never reaches the
database, and every authorization decision is made by FastAPI on its own authority — never
inherited from a header Next.js sets. If that ever changes, the boundary becomes decorative
and the whole split loses its point.

| Layer | Choice |
|---|---|
| `web/` | Next.js 15 App Router, TypeScript, Tailwind v4 — rendering, animation, form UX only |
| `api/` | FastAPI + Pydantic v2, SQLAlchemy 2.0 async, Alembic, Python 3.12, uv |
| `api/app/worker/` | Same image and package as the API, different command — APScheduler |
| Database | PostgreSQL 16 — the only stateful service |
| Proxy | Caddy routes `/api/*` → FastAPI, everything else → Next.js (one origin, no CORS) |

**Postgres is the queue (D3, D5).** No Redis, no broker. `message_job` carries
`scheduled_for`, `status`, `attempts` and `idempotency_key`
(`invitation_id:schedule_id:channel`, inserted `ON CONFLICT DO NOTHING`). The worker claims
due rows with `SELECT ... FOR UPDATE SKIP LOCKED` and transitions status in one transaction.
That unique constraint *is* the exactly-once guarantee — do not add a second source of truth
for send state.

**A job may carry its own content, and a manual send is not deduplicated.** Both are
exceptions to the paragraph above, and both are narrow:

- `message_job.subject`/`body_text` non-null means an admin composed that message — for one
  guest, or as one of a batch — and `process_job` sends it instead of rendering a template;
  no template could reproduce it. Null is every scheduled and audience-bulk job, unchanged.
  These columns hold the guest's name and token URL, at up to wave scale now that a batch
  writes one per recipient, so they must **never** be rendered into the message log.
- **A job carrying stored content never requires an accepted invitation.** `process_job`
  derives that from the job rather than from its caller, because the worker claims these too
  — on retry and after a restart — and it passes `audience_accepted=True`. Deciding it at the
  call site is what made every batched invitation skip as "invitation is no longer accepted",
  silently. Reminders carry no stored body, so the live-status recheck that makes a
  cancellation retroactive is unchanged for them.
- `message_job.batch_id` groups the jobs of one batched send from the guest list and is its
  duplicate boundary: the key is `invitation:batch-<id>:channel`, and the id comes from the
  browser, so a double-clicked Send inserts nothing twice while a deliberate second batch
  still goes out. It is also what the progress poll and the single audit row read. There is
  no batch table — a batch is a set of jobs sharing that value and nothing else.
- Batched text is a **template**: `{guest_name}` and `{invitation_link}` are substituted per
  recipient at enqueue. That set is closed and validated before the first insert — an unknown
  placeholder reaching 300 inboxes cannot be recalled.
- Manual sends use `manual_idempotency_key`, which carries a random nonce, because an admin
  resending a lost invitation means it — the shared `direct` key made that a silent no-op
  reported as success. Waves still use `idempotency_key` and still deduplicate exactly.
- `override_quiet_hours` is set only by an admin who confirmed a send inside quiet hours.
  Planned waves never set it, so FR-6.9 is unchanged for everything automated.
- Suppression (`do_not_contact`, `email_invalid`, no address) is checked in the endpoint and
  again in `process_job`, and no confirmation flag overrides it.

**Worker jobs** (`api/app/worker/main.py`): hourly reminder planner, 30s sender poll, 5-min
stuck-job requeue, nightly rate-limit cleanup, 15-min failure alert. The planner is
idempotent, so a worker that was down self-heals on its next tick — there is deliberately no
missed-run recovery logic. RSVP confirmations don't wait for the poll; the API fires a
FastAPI `BackgroundTask` after commit, with the `message_job` row as the safety net.

**Auth (D7, amended by `add-admin-access-control`)** — an admin account is **one of two
kinds, fixed at creation** and never converted: a **Google account**, or a **username-and-
password account** a super admin created. That reverses half of D7 — passwords *are* stored
now, as Argon2id hashes — and restores roughly what PRD FR-4.1 asked for. TOTP is still out.

Google is the ID-token flow, no redirect and no callback route, so the OAuth client needs an
*Authorized JavaScript origin*, not a redirect URI, and the client secret is never read. A
verified Google account with no record is **not** rejected: it becomes a `PENDING` row in the
roster's approval queue. Password accounts never self-register.

The session is a **JWT valid for two days, in the same httpOnly cookie** — "JWT-based" is
about the token's format, not about handing it to JavaScript; in `localStorage` an XSS would
read it. It carries `sub`/`iat`/`exp`/`jti`/`sv` and **never the role**: role, status and
scope are re-read from the database every request, which is what makes two days safe. `sv` is
`admin_user.session_epoch`, bumped to drop outstanding tokens on a password change — a
counter rather than a timestamp because `iat` is whole seconds, and any time comparison either
signs out the admin who just changed their own password or leaves a one-second hole.

**Authorization is two independent checks, and both must pass.** `app/services/policy.py`'s
`can(role, action)` says what *kind* of thing a role may do; `app/services/scope.py` says
*which records*. Reading either alone is misleading — a Host may delete guests, but only under
events they own. Scope goes in the `WHERE` clause, never as a filter over an assembled answer,
or aggregates and exports read rows before discarding them. **Out-of-scope answers 404**
(indistinguishable from a fabricated id); a capability denial answers 403. A super admin's
scope fragment is `true_()`, so both roles run one code path.

Two roles: `super_admin` and `host`. A temporary password issued by a super admin **confines**
the session — `get_unconfined_admin` is applied to every admin route by default and opted out
of by exactly three (`/auth/me`, password change, sign-out). The frontend's `can()` hides
buttons and the layout redirects a confined session; neither enforces anything.

`test_admin_route_guards.py` walks `app.routes` and fails when a new `/admin` route has no
entry in its `SCOPE_COVERAGE` map. Add the route to the map *after* scoping the handler.

**Domain model** — the **Invitation (guest × event) is the unit of tracking**. Headcount
counts accepted invitations summing `party_size`. `rsvp` is one current answer per invitation
(unique FK makes double submission idempotent); `rsvp_history` is append-only, because a
cancellation must not erase that they once accepted.

**A guest belongs to exactly one event (design D11).** This is the rule most likely to be
violated by accident, because it inverts what the schema used to say:

- Phone and email are unique **per event**, not globally. The same person on two guest lists
  is two independent records, and that is correct.
- Guests are created, listed and imported **through the event** (`/admin/events/{id}/guests`).
  The destination comes from the route, so a request body or a CSV column cannot contradict it.
- `do_not_contact` and `email_invalid` therefore suppress **one event**. This is an accepted
  consent exposure, mitigated by unsubscribe copy that names the event it covers.
- Any figure spanning events counts **guest records, not people**, and must be labelled so.
- There is no cross-event navigation on the invitation page. Linking to "their other
  invitation" would mean guessing at an identity match from a bearer token.

**The host is per event, and the host is a parent — never the couple.** `event.host_name_1`
(required), `host_name_2` (optional) and `host_phone` (required) render as the "Invited By"
block at the foot of the invitation, two names joined by "And". The Mehedi and the Walima are
routinely hosted by different families, which is why this cannot live on `wedding`.

- `wedding.host_contact_phone` still exists but **no longer renders on the invitation page**.
  It now serves only the cancellation surface and the RSVP-closed message. Do not reintroduce
  it into `InvitationShell` — two host numbers on one page is the thing this replaced.
- The label and the joining word are `Dictionary` keys (`invitedBy`, `hostNameJoin`), because
  they are guest-facing copy. The host *names* are never translated: they are data an admin
  typed, and a person's name is not translated.
- Events migrated from before this change carry the placeholder host name `Set host name` and,
  where the wedding had no number, the sentinel phone `+880000000000`. Both are deliberately
  visible rather than plausible — **an event still showing either has never been given a real
  host**, and a guest can see it.

**Card designs** — an event may have one published `event_card_design` and any number of
drafts, enforced by a partial unique index rather than by the publish endpoint remembering to
demote the old one. The design is a self-contained HTML file authored by the design team,
sanitised and reference-rewritten on upload (`app/services/card_html.py`), stored inline in
`config`, and rendered inside a declarative shadow root so its CSS and the page's cannot reach
each other. Companion files live on the **local filesystem** under `media/`, content-addressed
and served by Caddy with `immutable` — `app/services/media.py` is the only module that touches
that filesystem.

**Rendering (D8)** — `/e/{slug}` is ISR-cached (public, identical for everyone);
`/i/{token}` is always dynamic, `private, no-store`, and fetched **server-side** so the
token never appears in a browser request URL. Animations are client components layered over
server-rendered text: the invitation text must be complete in the initial HTML.

## Conventions that are easy to violate

- **Never use `process.env.NEXT_PUBLIC_*`.** Next inlines it at build time; the production
  web image is built with none of them set, so the read compiles to `undefined` and the
  container's environment is ignored — works in `next dev`, breaks only in production. Read
  runtime values through `web/lib/public-config.ts` and pass them down as props. An ESLint
  rule fails the build on the prefixed form.
- **No hand-written API types in the frontend.** Everything derives from
  `web/lib/api/schema.d.ts` (generated). Change a Pydantic model → run `make client` →
  commit the result, or CI fails.
- **PII rule (D8):** server components map API responses to explicit view models
  (`web/lib/view-models.ts`) before passing anything into a client component — never spread
  a full guest object, since client props are serialized into the HTML payload. The card is
  the same rule from the other side: it is fixed for the event and carries **no** guest data,
  so the guest's name appears in the header and nowhere else.
- **The card's `dangerouslySetInnerHTML` is load-bearing, not laziness.** In
  `InvitationCard.tsx` the `<template shadowrootmode>` is emitted as one opaque HTML string
  because React does not reconcile the children of such a node during hydration. Written as a
  JSX `<template>` it works on the server and then hydration replaces the parser's shadow root
  with an inert element, and the card silently vanishes.
- **Anything that backs up the database must back up `media/` too.** A database-only backup
  restores card records pointing at files that no longer exist: the row survives and the card
  does not. `make backup` writes both with a matching timestamp and `make restore` picks the
  archive up automatically.
- **Compose overlays are always explicit.** `docker-compose.override.yml` is deliberately
  unused so a production command can never silently inherit dev settings. Also:
  `--env-file` only feeds `${...}` interpolation in the compose file, it does **not** put
  variables inside containers — `env_file:` is what does that.
- **Tokens are bearer secrets (D11):** 22-char `secrets.token_urlsafe(16)`, never logged in
  full (use the `mask_token` helper). Short codes are independently random from an alphabet
  omitting `0/O` and `1/I/L` because guests type them off a printed card.
- `config.py`'s `assert_production_ready()` refuses to boot prod on dev placeholders
  (session secret, pepper, Turnstile test keys, `AUTH_DEV_BYPASS`). Add new secrets to that
  check rather than trusting deployment discipline.
- Sign-in failures all return one generic message — a specific one would turn the login page
  into an allowlist-membership oracle.
- All reminder math is timezone-aware in `Asia/Dhaka`, stored as UTC, unit-tested on edge
  dates. Quiet hours 22:00–08:00 defer rather than drop.
- Python: ruff (line length 100, `E,F,I,N,UP,B,C4,SIM,RUF`) and **mypy strict**. Migrations
  under `alembic/versions/` are exempt from import sorting.

## Working with OpenSpec

Work is tracked as OpenSpec tasks in `openspec/changes/add-rsvp-v1/tasks.md`. Slash commands
live in `.claude/commands/opsx/` (`propose`, `apply`, `update`, `sync`, `archive`, `explore`)
with matching skills in `.claude/skills/`. `openspec/config.yaml` carries the project context
that seeds new artifacts — owner decisions recorded there (email-only v1, the auth model,
split architecture) deliberately override the PRD where they conflict.

**Every change gets its own worktree.** `/opsx:propose` first makes the user commit or park
anything loose on `master` — the worktree branches from the committed HEAD, so uncommitted work
would silently stay out of the change — then opens
`.claude/worktrees/change/<change-name>` on a branch off `master`. `/opsx:apply` only works
inside the one propose made, it never creates its own.

When apply finishes every task, delivery runs `make test`/`make lint` first. Anything unproven
— failing, skipped, or a suite that could not run — is raised as an explicit ignore-or-fix
question, and an ignored gap is written into the commit and PR body rather than dropped. Then
commit+push, then a PR into `master` carrying the warning to check the running app (the suite
is not the app), then merge. Nothing is committed, pushed, opened, or merged without the yes
that authorizes that step.

`.claude/settings.json` sets `worktree.baseRef: head` so the worktree branches from local HEAD.
That is load-bearing: the `fresh` default branches from `origin/master` and would lose the
commit propose just asked for.

The `opsx-branch-flow` skill in `.claude/skills/` holds the exact procedure and is the
authority — load it whenever an opsx workflow starts, resumes, or finishes. The pointers inside
the vendored `opsx/*` command and skill files are convenience only and will be lost if OpenSpec
regenerates them; this paragraph is what survives.

Current state: `add-rsvp-v1` phases 0–5 are implemented (core RSVP, admin dashboard,
messaging, reminders, security). `add-event-invitation-card` is implemented apart from the
destructive half of its restore rehearsal. Its themed-template and content-editor tasks are
marked superseded in place — do not build them twice. `add-guest-invitation-send` (send to
one guest) and `add-bulk-invitation-send` (select guests, edit both invitation-type messages,
send as a batch) are implemented. `add-admin-access-control` (two roles, event ownership and
per-host scope, JWT sessions, password accounts, the admin roster) is implemented; its
remaining tasks are the live verification pass against a running app.

Remaining across both: the Bangla toggle and locale chain, the accessibility and performance
passes, launch rehearsal, and the external setup (domain, deliverability, OAuth client,
Turnstile) that is launch-blocking but needed for none of the development work.

`make walkthrough` drives the whole customer-onboarding path — create event, upload and
publish a card, add guests, open their links, accept and decline — in one run. It is the
fastest way to confirm the system still works end to end after a change.
