# Design: add-rsvp-v1

## Context

Greenfield repo (no code yet). The PRD (§7) proposed a single Next.js full-stack app; the owner chose a **split architecture** instead — Next.js for rendering, FastAPI (Python) for all business logic and data access — to get an enforced trust boundary rather than a conventional one. This document records that architecture and the decisions the PRD left open.

Constraints that shape everything: 300–1,500 guests with traffic spikes right after bulk sends; messages must be exactly-once; the app handles PII behind bearer-token links; the operator is a single host family (not a SaaS), so operational simplicity beats elasticity.

Owner decisions layered on the PRD: v1 messaging is **email-only** (WhatsApp/SMS → v2), admin auth is **Google OAuth only** (no passwords, no TOTP), default locale **English**, hosting **single VPS with Docker Compose**.

## Goals / Non-Goals

**Goals:**
- A real trust boundary: the browser can never reach the database, and every authorization decision is made by the API on its own authority.
- One deployable Docker Compose stack (web, api, worker, postgres, caddy) that runs identically on a laptop and on the VPS, with Postgres as the only stateful service.
- Exactly-once messaging semantics enforced by the database, not by scheduler discipline.
- Invitation pages fast and readable on cheap Android phones, without JavaScript for the text.
- Type safety across the language boundary via generated clients, so the split does not become a source of drift.

**Non-Goals:**
- Multi-tenancy, horizontal scaling, zero-downtime deploys (a one-off wedding tolerates brief maintenance windows).
- Named plus-one sub-guests, seating, registry (v2 candidates per PRD §3.2).
- A public/third-party API — the API serves only our own frontend and provider webhooks.

## Decisions

### D1 — Monorepo, two applications
Single repository, two deployables: `web/` (Next.js 15 App Router, TypeScript — rendering, animation, form UX only) and `api/` (FastAPI — routing, domain logic, persistence, queue producers) with `api/worker/` sharing the same Python package for the reminder planner and email sender. Shared artifacts: the generated OpenAPI schema and the TypeScript client derived from it.
*Rationale:* one repo keeps the schema and its consumer versioned together, so a breaking API change and its frontend fix land in the same commit.

### D2 — Python stack
FastAPI + Pydantic v2 (validation and serialization at every boundary), SQLAlchemy 2.0 async + Alembic (migrations), APScheduler for the worker's timed jobs, `uv` for dependency management, ruff + mypy for lint and types, pytest + httpx for tests.
*Alternatives considered:* Celery and ARQ — both require a broker (Redis or RabbitMQ) whose only job here would be dispatching work we already track in Postgres. Rejected as an unnecessary service to run, back up, and reason about.

### D3 — No Redis: Postgres is the queue
There is no message broker and no Redis. The `message_job` table already carries `scheduled_for`, `status`, `attempts`, and `idempotency_key`, so it is the queue. The worker claims due rows with `SELECT ... FOR UPDATE SKIP LOCKED LIMIT n`, which lets workers take disjoint batches without blocking each other, and updates status transactionally.
*Rationale:* the throughput requirement is ≥1,000 messages per 10 minutes (~1.7/sec) and reminder timing is day-scale, so polling costs nothing and removes a stateful service from the stack. Redis would have added a second source of truth that D5 explicitly does not want.
*Trade-off:* sends have up to one poll interval (30s) of latency. Irrelevant for reminders; for the immediate RSVP confirmation, the API fires the send attempt in a FastAPI `BackgroundTask` right after committing, with the `message_job` row as the safety net if that attempt fails or the process dies mid-flight.
*Scaling escape hatch:* `SKIP LOCKED` already makes the design correct with multiple worker processes if volume ever demands it.

### D4 — Same-origin deployment via reverse proxy (no CORS)
Caddy terminates TLS and routes `/api/*` to FastAPI and everything else to Next.js, so browser and API share one origin. Session cookies are httpOnly, secure, sameSite=Lax and need no cross-origin configuration; there are no preflight requests to misconfigure.
*Alternative considered:* separate `api.` subdomain with CORS and a parent-domain cookie — more moving parts and a classic source of subtle auth bugs, with no benefit at this scale.

### D5 — Exactly-once = DB unique constraint
`message_job.idempotency_key = invitation_id:schedule_id:channel` with `INSERT ... ON CONFLICT DO NOTHING` (PRD §7.3). The sender claims and transitions job state in one transaction, so a second worker or a restarted process finds the row already `sending`/`sent` and moves on. There is exactly one source of truth, which is the whole reason no broker is needed.

### D6 — Reminder engine: hourly planner + status-rechecking sender
Per PRD §7.3, implemented as two APScheduler jobs in the worker process. The **planner** runs hourly: it computes `send_at = starts_at − offset_days` at the configured local time in Asia/Dhaka, converts to UTC, skips waves already in the past, and inserts `message_job` rows idempotently. The **sender** runs every 30 seconds: it claims due rows (`status = 'queued' AND scheduled_for <= now()`) with `FOR UPDATE SKIP LOCKED`, re-reads each invitation's live status (cancellation-aware skip), applies quiet hours by deferring `scheduled_for` to 08:00, renders the template, passes through the rate limiter, sends, and records the provider message id.
Because the planner is idempotent and runs hourly, a worker that was down for any length of time self-heals on its next tick — no missed-run recovery logic is needed. All date math uses timezone-aware `datetime` and is unit-tested against edge dates.

### D7 — Auth: FastAPI owns the Google sign-in flow
The Google Identity Services button in the browser obtains an ID token and posts it to FastAPI, which verifies its signature, audience and issuer against Google's public keys, matches the verified email against active `admin_user` rows (allowlist — no auto-provisioning), and issues its own signed session cookie. There is no redirect and no callback route, so the OAuth client needs an **Authorized JavaScript origin** rather than a redirect URI, and the client secret is never used. Next.js never mints or validates credentials; it forwards the cookie on server-side calls and renders whatever the API authorizes.
Role is re-read from the database on every request rather than baked into the token, so a demotion or deactivation applies immediately. Authorization lives in one `can(role, action)` policy module invoked by FastAPI dependencies — never in the frontend, which only hides UI.
*Rationale:* this is the reason for the split. If the API trusted a header from Next.js, the boundary would be decorative.
*Trade-off:* admins need Google accounts (they do), and there is no fallback login if Google is unreachable — acceptable for v1.

### D8 — Rendering strategy
`/e/{slug}` (open/QR) is server-rendered with ISR — public, cacheable, identical for everyone. `/i/{token}` is always dynamic, `Cache-Control: private, no-store`, never cached by proxy or CDN, and fetched server-side by Next.js so the token never appears in a client-side request URL. Admin pages are dynamic behind the session cookie. Animations are client components layered over server-rendered text, satisfying "text is never gated behind JavaScript."
**PII rule:** Next.js server components map API responses to explicit view models before passing anything to client components — never spread a full guest object, because client component props are serialized into the HTML payload.

### D9 — Message providers
A Python `MessageProvider` protocol (`send`, `parse_webhook`) with one v1 implementation (Resend or SES email) plus a dry-run decorator enabled by `DRY_RUN=true`. The `message_job.channel` enum and template schema stay channel-shaped so v2 adds WhatsApp/SMS as new provider modules without touching the pipeline. The webhook route verifies the provider signature before parsing. The PRD's fallback chain (FR-6.2) collapses in v1 to: send by email if an address exists, else mark the job `skipped` with a visible no-email reason — never a silent drop.

### D10 — Throttling and rate limiting
Provider throttling is an in-process token bucket per channel in front of send calls, with exponential-backoff retry (3 attempts: +1min, +10min, +1hr) recorded on the `message_job` row. In-process state is correct here because exactly one worker sends.
Per-IP rate limiting for public endpoints (FR-2.12: 5 RSVP submissions per IP per 10 minutes) uses a small Postgres table keyed by hashed IP and window, cleaned up periodically — durable across restarts, and correct even if the API runs more than one process.

### D11 — Tokens
22-character URL-safe strings from 16 random bytes (`secrets.token_urlsafe(16)`), stored with a unique index. Tokens are high-entropy bearer secrets: never logged in full (a `mask_token` helper exists so logging one is the awkward path), `Referrer-Policy: no-referrer` on invitation pages, and `Cache-Control: private, no-store` on `/i/{token}`.

Short codes are independently random from a 31-character alphabet that omits `0/O` and `1/I/L`, because guests read them off a printed card and type them by hand. Collisions are plausible at that size rather than theoretical, so allocation retries against a uniqueness check.
*Amended during implementation:* this decision originally derived short codes from the token via `TOKEN_PEPPER`. Independent random generation is simpler and equally unguessable — deriving would have added a construction to reason about for no security gain, since both are checked for uniqueness anyway. `TOKEN_PEPPER` is still used, as the salt for hashing IP addresses in rate limiting and abuse detection.
*Alternative considered:* hashing tokens at rest — deferred; the threat model is a leaked backup, mitigated by encrypted backups and the 90-day PII purge.

### D12 — Contract between the two apps
FastAPI emits OpenAPI from Pydantic models; CI regenerates the TypeScript client (`openapi-typescript` + a typed fetch wrapper) and fails if the committed client is stale. A field renamed in Python breaks the TypeScript build rather than failing silently at runtime — this is what keeps the split from producing model drift.

### D13 — i18n
Two locales (English default, Bangla), resolved: explicit toggle > guest `preferred_locale` > English. UI strings live in Next.js dictionaries; message templates live in the database with locale variants, rendered by the API. No i18n framework — two locales do not justify the tooling.

### D14 — Two environments, one topology, driven by a Makefile
`docker-compose.yml` defines the five services — `web`, `api`, `worker` (same image as api, different command), `postgres:16`, `caddy` — and two overlays specialize it. Overlays are named explicitly (`docker-compose.dev.yml`, `docker-compose.prod.yml`) rather than using Compose's auto-loaded `docker-compose.override.yml`, so nothing is applied implicitly and a production command can never silently pick up development settings.

A `Makefile` is the only entry point anyone types: `make dev` and `make prod` bring the respective stacks up, with `make migrate`, `make seed`, `make test`, `make lint`, `make client`, `make logs`, `make psql`, `make backup`, and `make down` covering the rest. This keeps the long `docker compose -f ... -f ...` invocations out of human hands, which is where environment mix-ups come from.

The two environments differ deliberately:

| | dev | prod |
|---|---|---|
| Email sending | `DRY_RUN=true` — rendered and logged, never sent | live provider |
| Source | mounted, hot reload | baked into the image |
| Postgres port | exposed on localhost for a GUI client | not published |
| TLS | plain HTTP on localhost | Caddy with the real domain and HSTS |
| Restart policy | none | `unless-stopped` |
| Data | seeded fake guests | real data, nightly backups |

Secrets live in `.env.dev` and `.env.prod`, both gitignored, with a committed `.env.example`. Google sign-in needs both Authorized JavaScript origins registered on the OAuth client (localhost and production) — not redirect URIs, since D7 uses the ID-token flow — and dev uses Cloudflare Turnstile's documented test keys.

Postgres is the only stateful service, so there is one thing to back up and one thing to restore. Alembic migrations run against whichever environment is targeted. Nightly `pg_dump` to S3-compatible storage with 30-day retention; restore tested once before launch. CI runs the dev stack: ruff + mypy + pytest for the API, lint + typecheck + build for the web app, generated-client freshness check, then Playwright E2E against the composed stack.

*Note:* `make` is not installed on Windows by default; it comes with Git Bash environments via `scoop install make` or `choco install make`. If that proves annoying, `just` or `Taskfile.yml` provide the same ergonomics cross-platform — the compose overlays are the substance, the Makefile is the wrapper.

## Risks / Trade-offs

- [Two languages, two deploys — more surface, slower iteration] → Mitigated by the monorepo, one compose file, and the generated client; accepted deliberately in exchange for an enforced trust boundary.
- [Model drift between Pydantic and TypeScript] → CI fails on a stale generated client (D12); no hand-written API types on the frontend.
- [Polling worker adds up to 30s send latency, and a stopped worker sends nothing] → Irrelevant for day-scale reminders; RSVP confirmations fire immediately via a background task. The worker's liveness is covered by the health endpoint and uptime alert, and the idempotent planner means a restart loses nothing.
- [SSR latency: browser → Next.js → API → Postgres adds a hop] → Same-host container networking makes the hop sub-millisecond; the invitation page's LCP budget is dominated by images, not this.
- [Email-only reach: many BD guests may lack or ignore email (PRD risk #6 amplified)] → Send screen and dashboard surface no-email counts; printed QR cards and shared links cover the gap; admins record phone RSVPs manually; WhatsApp/SMS land in v2 on the same pipeline.
- [Emails land in spam (PRD risk #4)] → SPF + DKIM + DMARC on a verified domain, warmed by the soft launch, plain-text alternative, no spam-trigger wording. This is now the primary delivery risk — deliverability setup is launch-blocking.
- [Single VPS is a single point of failure] → Acceptable for a wedding; nightly backups with a tested restore, health endpoint, uptime monitor with email alerts.
- [Bulk-send traffic spike (PRD risk #8)] → ISR-cache the open invitation page, serve assets via CDN, load-test 1,500 guests before launch.
- [Timezone errors in wave planning] → All reminder math timezone-aware in Asia/Dhaka, unit-tested on edge dates, stored as UTC.

## Migration Plan

Greenfield: Alembic migrations from an empty database. Deploy order: postgres → `alembic upgrade head` → api → worker → web. Rollback is a redeploy of the previous images; destructive migrations are avoided until after launch. The 90-day PII purge job ships disabled and is enabled at launch.

## Open Questions

- Exact event dates and venues (PRD open Q1) — needed before enabling reminders, not before building them.
- Email provider final pick, Resend or SES — identical behind the provider protocol; Resend recommended for setup speed.
- (v2) Which WhatsApp path and SMS gateway to use when those channels are added — deferred with the channels themselves.
