# Tasks: add-event-invitation-card

Groups 1–2 are done and their work is unchanged by the revision. Group 3 (event and guest
scoping) is the new foundation everything else sits on, so it comes next.

**Order:** 1–2 ✓ → 3 → 4 → 6 → 7 → 5 → 8 → 9 → 10 → 11 → 12.
Groups 4/6/7 are the vertical slice: the greeting API plus the recomposed header/body/footer
and the drawer/sheet. That puts a visibly different invitation on a phone well before the
card authoring pipeline is finished, with the body showing the existing cover image until
group 5 replaces it with a real uploaded card.

`(api)`, `(web)`, `(infra)` mark where the work lives. Decisions are cited as `D1`–`D12`
against `design.md`.

## 1. Media storage and infrastructure

- [x] 1.1 (infra) Add a `./media` volume to `docker-compose.yml`: read-write into `api`, read-only into `caddy`. Ensure the api container's user can write it, and add `media/` to `.gitignore`
- [x] 1.2 (infra) Serve `/media/*` from Caddy directly off the volume with `Cache-Control: public, max-age=31536000, immutable` — never through FastAPI. Add the handler to both `Caddyfile.dev` and `Caddyfile.prod`
- [x] 1.3 (api) Storage seam (D6): a module exposing `save(data, suffix) -> url` and `delete(url)` writing to `/srv/media/cards/{event_id}/{sha256[:16]}.{ext}`. Nothing else in the codebase touches the media filesystem, so the later S3 swap is one file
- [x] 1.4 (api) Upload guards: content-type allowlist, per-file size ceiling, and SVG sanitisation stripping `script`, `foreignObject`, `on*` handlers and external references. Rejections name the actual size or type against the limit
- [x] 1.5 (infra) Extend `make backup` to cover `./media` alongside `pg_dump`, and `make restore` to put it back (D6). Without this a restore produces card rows pointing at files that no longer exist
- [x] 1.6 (api) `MEDIA_ROOT`, `MEDIA_MAX_BYTES` and the allowlist in `config.py`, with sane dev defaults

## 2. Data model — cards and greeting

- [x] 2.1 (api) `event_card_design` model: `event_id`, `renderer` enum, `config` JSONB, `assets` JSONB, `version`, `status`, `published_at`, `created_by`, timestamps. Partial unique index enforcing at most one published row per event
- [x] 2.2 (api) `guest.invitation_type` enum (`single`, `family`) with `server_default='single'` so existing rows are valid without a backfill (D8)
- [x] 2.3 (api) `wedding.invitation_messages` JSONB defaulting to `{}`, keyed `{locale}.{type}` (D9)
- [x] 2.4 (api) One Alembic migration covering 2.1–2.3, reversible, all additive. Verify `alembic upgrade head` then `downgrade` runs clean against a seeded database

## 3. Events as records, guests scoped to them

- [x] 3.1 (api) Second migration: add `html` to the `card_renderer` enum; add `guest.event_id` (FK, cascade); replace `uq_guest_wedding_phone`/`uq_guest_wedding_email` with per-event equivalents; drop `uq_event_wedding_type` (D11, D12). **Take a backup first — the guest split is not cleanly reversible**
- [x] 3.2 (api) Data step in the same migration: split a guest holding invitations to several events into one record per event, carrying contact details, locale, tags and `invitation_type`, each keeping its own invitation and token. Pre-launch the practical path is a re-seed; the split exists so the migration is correct either way
- [x] 3.3 (api) `Guest.event_id` on the model with the relationship; a guest holds exactly one invitation, to its own event
- [x] 3.4 (api) Event create and delete routes behind `Action.EDIT_CONTENT`. Create takes type, name, Bangla name, date, venue; slug derived from the name and de-duplicated on collision; slug never changes on rename (D12). Delete requires confirmation and reports what it destroys
- [x] 3.5 (api) Scope guest list, create, update, delete and CSV import to an event. Duplicate detection operates within the event only; a phone or email already used under another event is not a duplicate
- [x] 3.6 (api) Per-event contact suppression: `do_not_contact` and `email_invalid` apply to the guest record, so they suppress that event alone. Unsubscribe copy names the event it covers
- [x] 3.7 (api) Remove the multi-event switcher from the token payload — no cross-event link survives the scoping change (D11)
- [x] 3.8 (api) Update the seed script: create the three events through the same path an admin would, and seed guests under their event
- [x] 3.9 (api) Tests: two events of one type coexist, slug collision resolves, same phone under two events accepted, duplicate within one event refused, CSV import dedupes within the event only, unsubscribe suppresses one event and not another, event delete cascades to guests and invitations
- [x] 3.10 (web) Label any figure spanning events as guest records rather than people, and remove the cross-event link from the invitation page

## 4. Greeting API

- [x] 4.1 (api) `invitation_type` on the guest read and write schemas; accepted by create and update, returned by list and detail. CSV import maps it when present and defaults to `single` when absent
- [x] 4.2 (api) Invitation-message validation (D9): ≤100 characters after normalisation, markup stripped or rejected, consecutive newlines collapsed, trimmed. Applied on the wedding settings write path
- [x] 4.3 (api) Resolve the greeting server-side — guest's `invitation_type` → customer message for the resolved locale → built-in default — and return it on the token and open payloads. A null or empty greeting must be unreachable
- [x] 4.4 (api) Tests: all four locale×type combinations, partial customisation falling back per key, over-limit rejection, markup submission, a guest with no explicit type resolving as `single`
- [x] 4.5 Regenerate the TypeScript client (`make client`) and commit it — CI fails on a stale `schema.d.ts`

## 5. Card upload and rendering

- [x] 5.1 (api) HTML sanitiser (D4): strip `script`, `on*` handlers and references to external hosts from uploaded card documents, storing the sanitised bytes. Rejections name the offending reference rather than dropping it silently
- [x] 5.2 (api) Asset reference rewriting (D5): accept a card document plus its companion files in one upload, store each, rewrite every relative reference to its stored content-addressed URL, and refuse publication when a reference has no matching file
- [x] 5.3 (api) Config models as a discriminated union on `renderer`: `html` (document asset key, companion assets) and `image` (per-locale alt text). Saving `component` or `layered` is rejected naming the supported renderers (D1)
- [x] 5.4 (web) Extend the view models with an explicit card view model, carrying the sanitised document and no guest fields
- [x] 5.5 (web) `html` renderer: a server component emitting `<template shadowrootmode="open">` with the stored document and its styles (D3). Verify styles do not escape into the page and page styles do not reach into the card
- [x] 5.6 (web) `image` renderer: natural aspect ratio, no stretch or crop, locale-resolved alt text, entry and parallax motion only
- [x] 5.7 (web) Full-screen card view: focus trap, Escape, back gesture, scroll position preserved on return
- [x] 5.8 (web) Verify a published `html` card renders complete, styled and animated with JavaScript disabled, and renders legibly on a browser without declarative shadow DOM support
- [x] 5.9 (api) Tests: script and handler stripping, external reference rejection, relative reference rewriting, missing companion file blocking publication, one-published-per-event, upload size and type rejections

## 6. Invitation composition

- [x] 6.1 (web) Re-compose `InvitationShell` into header / body / footer, with the countdown, date, venue, directions, notes and host contact moved below the call to action (D7)
- [x] 6.2 (web) Header: `Dear {name},` plus the resolved greeting. Transparent, centred, no card or border, restrained against the artwork. Names wrap rather than truncate. The tokenless route renders the greeting with no name line and no guest data. This is the only place a guest is named — the card never is
- [x] 6.3 (web) Footer: a single Accept call to action, with Decline retained. Nothing else renders in the flow beneath the artwork
- [x] 6.4 (web) Verify the first screen at each supported width contains greeting, card and CTA only, and that the header occupies a minor share of vertical space next to the card

## 7. Responsive RSVP surface

- [x] 7.1 (web) `RsvpSurface`: one component holding dialog semantics, focus trap, focus return to the trigger, Escape, scroll lock and `overscroll-behavior: contain`, wrapping the existing `RsvpForm` unchanged
- [x] 7.2 (web) Presentation chosen by CSS media query — right drawer on wide viewports, bottom sheet on narrow — with position, size, transform origin and entry keyframe all switched in CSS, never by measuring the viewport in JavaScript (D10)
- [x] 7.3 (web) Mobile viewport handling: `dvh` not `vh`, `env(safe-area-inset-bottom)` padding, `visualViewport` resize handling so the focused field and the submit control stay visible with the keyboard open, internal scrolling when content overflows
- [x] 7.4 (web) Rework `InvitationInteractive` so the `form` stage renders into the surface instead of the page flow. `idle`, `success`, `declined`, `closed`, `cancelled` and the already-responded answer with update and cancel all keep their existing behaviour
- [x] 7.5 (web) Dismissal never silently discards entered data — backdrop dismissal warns or is disabled once a field has been touched

## 8. Admin

- [x] 8.1 (web) Events screen: create an event (type, name, Bangla name, date, venue), list events, delete with a confirmation naming the guests and responses destroyed
- [x] 8.2 (web) Event detail with the guest list scoped to it, and Add guest from inside the event
- [x] 8.3 (web) Card design tab on the event: upload the card document and its assets, see total weight against the budget, preview, publish, roll back. Behind the content-editing permission
- [x] 8.4 (web) Phone-frame preview rendering the card exactly as a guest sees it, with no effect on what guests currently see
- [x] 8.5 (web) Warn on saving an event date or venue change while a card is published — the live card shows the old detail and must be re-uploaded (D2)
- [x] 8.6 (web) `Invitation Type` control in `GuestForm` for add and edit, beside the existing Seats field. Warn — do not silently correct — when `family` is paired with a seat ceiling of 1 (D8)
- [x] 8.7 (web) Invitation message settings: two fields per locale with a live character counter against 100, and helper text explaining that the limit protects the design
- [x] 8.8 (web) Verify a Viewer role receives 403 from every event, guest and card design endpoint called directly, not merely a hidden button

## 9. Fonts and performance

- [x] 9.1 (web) Self-host one subset Bangla webfont with `font-display: swap`, subset to the ranges actually rendered. Confirm it inherits through the shadow boundary into an uploaded card
- [x] 9.2 (web) Confirm the invitation carrying a published card stays under 800KB initial transfer and LCP under 2.5s on emulated 3G mid-range mobile, with the card as the LCP element

## 10. Responsive and behavioural verification

- [x] 10.1 Viewport matrix at 320, 360, 375, 390, 414, 430, 768, 1024, 1280 and 1440px: no horizontal scrolling, no clipped names, no distorted or cropped artwork, no overlapping sections, no control outside the viewport
- [x] 10.2 Content matrix: short and very long guest names, single and family types, default and at-limit customised messages — **including a full-length Bangla message at 320px**, which is the case an English-only check will miss (D9)
- [x] 10.3 Mobile behaviour: portrait and landscape, keyboard open, safe-area device, browser zoom, and scroll chaining behind the open sheet
- [x] 10.4 Accessibility: keyboard-only open/complete/close of the RSVP surface, focus trap and return verified, dialog announced, reduced-motion resolving the card to its settled state
- [x] 10.5 Regression: an invitation link issued before this change resolves to the same guest and event and stores an identical RSVP result; cancellation, headcount, locale resolution and admin permissions unchanged
- [ ] 10.6 Restore rehearsal: back up, wipe, restore, and confirm a published card still renders — proving 1.5 actually covers the media (D6)
  - Verified: `make backup` writes `backups/media_<stamp>.tar.gz` beside the dump, and its
    contents match every live file under `media/` byte for byte (5 files, sha256 compared).
    So 1.5 does cover the media.
  - **Not yet done:** the wipe-and-restore half. It drops the dev database and deletes
    `media/`, so it needs an explicit go-ahead before running.

## 11. End-to-end walkthrough

- [x] 11.1 Create an event, upload a real card with its artwork, publish it, add a single guest and a family guest, open both invitation links on a phone, submit one RSVP and decline the other — the whole customer-onboarding path in one pass, with no engineer involved after the card file exists

## 12. Documentation

- [x] 12.1 Write the authoring contract for the design team: single root element, styles contained in the file, no scripting, no external hosts, companion assets referenced by relative filename and uploaded together, CSS-keyframe motion with a reduced-motion branch, container-relative units, and the event's names, date and venue typed in as fixed text (D2, D5)
- [x] 12.2 Document the customer-onboarding flow in the user guide: create event → upload card → add guests → send. Include what the designer delivers and the size ceiling
- [x] 12.3 Note in `add-rsvp-v1` that FR-1.10's multi-event switcher is removed, its `guest-management` spec now assumes event-scoped guests, and tasks 6.1 and 6.4 are superseded — so none of it is built twice at archive time
