# Tasks: add-rsvp-v1

Grouped by the PRD delivery phases (§10), adapted for the split architecture: `api/` is FastAPI + Python, `web/` is Next.js. P0 unless noted. Group 8 collects security work that would otherwise be invisible and skippable under time pressure.

## 1. Phase 0 — Setup

- [x] 1.1 Monorepo skeleton: `api/` (FastAPI, uv, ruff, mypy, pytest), `web/` (Next.js 15 App Router, TypeScript, Tailwind, shadcn/ui), root `docker-compose.yml`
- [x] 1.2 SQLAlchemy 2.0 models + Alembic for the full PRD §4.2 schema (wedding, event, guest, invitation, rsvp, rsvp_history, message_template, reminder_schedule, message_job, admin_user, audit_log) plus a `rate_limit` table, with uniqueness rules and enums — `admin_user` holds Google email + role, no password_hash or totp_secret; index `message_job (status, scheduled_for)` for the sender's claim query
- [x] 1.3 Base `docker-compose.yml`: web, api, worker, postgres:16, caddy with `/api/*` reverse proxy (no Redis — Postgres is the queue)
- [x] 1.4 Two explicit overlays — `docker-compose.dev.yml` (mounted source, hot reload, exposed Postgres port, plain HTTP, `DRY_RUN=true`) and `docker-compose.prod.yml` (baked images, no published DB port, Caddy TLS with the real domain, `restart: unless-stopped`); `.env.example` plus gitignored `.env.dev` / `.env.prod`
- [x] 1.5 `Makefile` as the single entry point: `dev`, `prod`, `down`, `migrate`, `migration`, `seed`, `test`, `lint`, `client`, `logs`, `psql`, `backup`; every target passes explicit `-f` compose files so no environment is ever selected implicitly
- [x] 1.6 Verify `make dev` produces a working stack on Windows via Git Bash, and document the `make` install step in the README — verified end to end: all five containers up, migration generated and applied, seed loaded, API healthy through Caddy.
- [x] 1.7 OpenAPI → TypeScript client generation with a CI freshness check that fails on a stale client
- [x] 1.8 CI pipeline running the dev stack: ruff + mypy + pytest (api), lint + typecheck + build (web), generated-client check
- [x] 1.9 Seed script: one wedding, three events (Mehedi/Marriage/Walima), sample guests, default email templates and reminder schedules, first Super Admin Google account — dev-only guard so it cannot run against prod
- [ ] 1.10 (external — **requires you, not code**) Domain purchase, email provider account, SPF/DKIM/DMARC records, a Google Cloud OAuth client with **both** localhost and production **Authorized JavaScript origins** registered (not redirect URIs — D7 uses the ID-token flow, so no callback route exists and the client secret is never read), and a Cloudflare Turnstile site/secret pair for the domain — deliverability and OAuth setup are launch-blocking. None of these are needed for development: dev runs on Cloudflare's always-pass test keys and a Google dev bypass, and `make prod` refuses to start on either, so the placeholders cannot reach production

## 2. Phase 1 — Core RSVP

- [x] 2.1 (api) Token + short-code generation with `secrets`, uniqueness guarantees, unit tests
- [x] 2.2 (api) Invitation read endpoints for token and open-slug routes: personalised payload, opened_at/open_count tracking, generic not-found for bad tokens, draft-event 404
- [x] 2.3 (web) `/i/{token}` server-rendered page — dynamic, no-store, server-side API fetch so the token never appears in a browser request; view-model mapping so no raw PII reaches client components
- [x] 2.4 (web) `/e/{eventSlug}` open page with ISR
- [x] 2.5 (web) Invitation sections: hero, countdown, event details, map/directions, contact footer; one themed animated template (envelope reveal) with skip control, reduced-motion fallback, muted-by-default music toggle — the reveal is a client component that renders nothing on the server, so the invitation text is complete in the initial HTML and a visitor with no JS simply never sees an envelope (PRD §9.2). Verified live: overlay mounts with the page scroll locked and focus on Skip; Skip, Escape and tap-anywhere all dismiss it and restore the scroll; it plays once per tab per event; `prefers-reduced-motion: reduce` skips it entirely rather than speeding it up. Music is off until asked for — mobile browsers refuse ungestured audio, so autoplay would just fail silently
- [x] 2.6 (web) Accept-gated RSVP form: reveal on Accept, client validation, BD phone input, pre-fill, party-size ceiling
- [x] 2.7 (api) RSVP submit endpoint: Pydantic validation, phone normalisation to E.164 (phonenumbers), idempotent upsert per invitation, rsvp_history append, status lifecycle (§4.3) in one domain service with unit tests
- [x] 2.8 (web+api) Decline path and "we'll miss you" screen
- [x] 2.9 (web+api) Success screen: confetti, summary, Add-to-Calendar with an `.ics` endpoint and Google Calendar link
- [x] 2.10 (api+web) Open-link flow (verified live: registration, dedup, party-size ceiling, rate limiting; server-side Turnstile verification added with fail-closed tests): event checkboxes, guest + invitation creation, duplicate merge on phone/email, Turnstile verification, Postgres-backed per-IP rate limit (5 per 10 min) with window cleanup
- [x] 2.11 (api+web) Cancellation: confirmation page, POST-only with CSRF, headcount effect, future-job skipping, re-accept, post-start block
- [x] 2.12 (web) Deadline and already-responded page states
- [ ] 2.13 Playwright E2E against the composed stack: tokenized journey, open-link journey, cancel journey, mobile viewport

## 3. Phase 2 — Admin & Export

- [x] 3.1 (api) Google OAuth: ID token verification, allowlist match with no auto-provisioning and generic rejection, signed httpOnly session cookie, 12h expiry, per-request role re-read
- [x] 3.2 (api) `can(role, action)` policy module wired into FastAPI dependencies on every admin route; admin-user management with last-Super-Admin protection
- [x] 3.3 (api) Audit log service instrumented across guest changes, imports, sends, exports, settings, allowlist changes, rejected sign-ins, manual RSVP overrides; Super-Admin-only read endpoint
- [x] 3.4 (web) Admin shell, sign-in page, and role-aware navigation
- [x] 3.5 (api+web) Dashboard overview: three event cards with counts, headcount, response rate, countdown, plus aggregate row; 30s refresh
- [x] 3.6 (api+web) Guest table: search and filters (event/status/side/tag/channel/date), pagination 50/page against indexed queries, inline actions including manual accept/decline recorded with actor=admin
- [x] 3.7 (api+web) Guest add/edit with E.164 normalisation and per-event invitation management — this shipped broken and had no test: `guest.invitations = []` lazy-loaded the collection it was initialising, so every create died with MissingGreenlet and a 500. Fixed, plus the follow-on where the identity-map instance returned the stale empty collection in the response. Now covered by `test_admin_guest_create.py` against real Postgres
- [x] 3.8 (api+web) CSV import: upload, column mapping, 10-row preview, per-row validation report, duplicate merge, invitation creation from the `events` column
- [x] 3.9 (api) CSV export: filtered streaming response, fixed columns, UTF-8 BOM, filename convention, caterer-list preset, audit entry per export
- [x] 3.10 (api+web) QR generation: per-event SVG and 1024px PNG at error-correction H, short URLs, download UI with print-size guidance; per-guest QR sheet (P1)
- [x] 3.11 (web) Guest detail drawer with full timeline (P1)
- [ ] 3.12 E2E: sign in → import → filter → export

## 4. Phase 3 — Messaging (email-only in v1)

- [x] 4.1 (api) `MessageProvider` protocol + dry-run decorator + provider registry keyed by channel, shaped for v2 WhatsApp/SMS
- [x] 4.2 (api) Email provider (Resend or SES): responsive HTML plus plain-text templates; bounce and complaint webhook with signature verification marking addresses invalid
- [x] 4.3 (api) Template management: seed invite, reminder_15/7/2, cancel_confirm, thank_you in bn and en; render service with placeholder substitution and links under 60 characters
- [x] 4.4 (api) message_job pipeline on Postgres: `FOR UPDATE SKIP LOCKED` claim query, in-process token-bucket throttle, 3-attempt backoff (+1m/+10m/+1h), transactional status transitions, no-email jobs marked skipped with a reason
- [x] 4.5 (api+web) Send screen: event → audience → preview → send or schedule, showing recipient count and no-email count
- [x] 4.6 (api+web) Message log: status, provider id, error reason, bulk retry-failed
- [x] 4.7 (api) RSVP confirmation email: job row written on accept, then attempted immediately via FastAPI BackgroundTask so it does not wait for the poll interval — the row remains the retry safety net
- [ ] 4.8 (api+web) Unsubscribe link in every email marking do_not_contact (P1); free-text broadcast to a filtered audience (P1)

## 5. Phase 4 — Automation

- [x] 5.1 (worker) Worker process with APScheduler: hourly planner job + 30s sender poll, graceful shutdown, single-instance guard
- [x] 5.2 (worker) Reminder planner: wave computation in Asia/Dhaka, past-wave skip, idempotent job insertion, audience selection — unit tests including timezone edges
- [x] 5.3 (worker) Sender: claim due jobs with SKIP LOCKED, live-status recheck with cancellation-aware skip, quiet-hours deferral to 08:00, render and send through the pipeline
- [x] 5.4 (api+web) Reminder settings per event: enable/disable waves, send time, wave preview with dates, recipient counts, and no-email counts
- [x] 5.5 (api) Date-change re-planning when `starts_at` is edited, plus a "date changed" broadcast offer
- [x] 5.6 (api) Health endpoint covering database, worker heartbeat, and provider reachability; email alert when a wave exceeds 5% failures
- [x] 5.7 Integration tests: double planner run creates no duplicates, restart mid-batch resends nothing, cancel between planning and sending is skipped, two concurrent senders never claim the same job

## 6. Phase 5 — Polish (P1)

- [~] 6.1 **Superseded by `add-event-invitation-card`.** Built-in themed templates are not
  the product any more: the card is a designed HTML file uploaded per event (design D1, D3),
  so there is no second and third template to write. Do not build this.
- [ ] 6.2 (web) Bangla ⇄ English toggle with the full bn dictionary and the locale resolution chain
- [ ] 6.3 (web) ~~Multi-event switcher~~ (**superseded** — guests are event-scoped, see
  `add-event-invitation-card` task 3.7); Web Share button (P2); no-JS POST fallback (P2)
- [~] 6.4 **Superseded by `add-event-invitation-card`.** The card design tab delivers the
  per-event editor and the phone-frame preview (tasks 8.3, 8.4), and media is stored on the
  local filesystem rather than S3 (design D4). What remains unbuilt here is only the cover
  photo and music upload, which the card renderer makes largely redundant.
- [ ] 6.5 (web) Trend and channel charts; capacity warning banner (P2); mobile dashboard pass
- [ ] 6.6 (web) Accessibility pass (WCAG AA contrast, focus rings, aria-live, 44px targets, 16px base) and performance pass (<800KB initial, LCP budgets, transform/opacity only)

## 7. Phase 6 — Hardening & Launch

- [ ] 7.1 Load test: 1,500 guests, 3,000 queued messages, 200 concurrent RSVP submissions
- [ ] 7.2 Full dry-run reminder cycle with shifted dates; send every template to the host's own inbox in both languages
- [ ] 7.3 Nightly `pg_dump` backup job plus one verified restore; 90-day PII purge job shipped disabled
- [ ] 7.4 Real-device test (low-end Android, iPhone SE) including a printed-QR scan at final size under indoor light
- [ ] 7.5 Soft launch to 20 friendly guests, then the full send

## 8. Security (cross-cutting, verified in Phase 6)

- [x] 8.1 Authorization enforced only in FastAPI dependencies — write a test that calls every admin endpoint directly with each role and asserts the matrix, proving the frontend is not the gate
- [x] 8.2 No PII in client component props: view-model mapping on every server-rendered page, with a test asserting phone and email never appear in the `/e/{slug}` HTML payload
- [x] 8.3 Token hygiene: never logged in full, `Referrer-Policy: no-referrer`, `Cache-Control: private, no-store` on `/i/{token}`, generic not-found for invalid tokens
- [x] 8.4 Webhook signature verification with tests for forged and replayed payloads
- [x] 8.5 Rate limits and Turnstile on public endpoints; CSRF on all state-changing requests; cancellation reachable only by POST, with a test asserting GET is inert — verified end to end in the browser: the widget renders, its token reaches the API in the request body, and a rejected token surfaces a readable message and re-issues the challenge rather than looping on a spent one. Fixed while wiring it: `NEXT_PUBLIC_*` is inlined at build time, so the site key would have been `undefined` in the production image and every open registration would have 403'd — public values now resolve at request time through `web/lib/public-config.ts`, with an ESLint rule blocking the prefixed form
- [x] 8.6 Security headers via Caddy (HSTS, CSP, X-Content-Type-Options, Referrer-Policy) and secrets audit — no credentials in the repo, database, or client bundle
- [x] 8.7 Dependency and version policy: pinned versions, `pip-audit` and `npm audit` in CI, advisories checked at scaffold time
- [x] 8.8 Environment isolation: assert `DRY_RUN=true` is the dev default, the seed script refuses to run against prod, and no `.env.dev`/`.env.prod` file is ever committed
