# Design: add-event-invitation-card

## Context

See proposal.md — Why. What matters here is the shape of what already exists.

`InvitationShell` is a server component that emits the whole invitation as text before any
JavaScript runs; `EnvelopeReveal`, `MusicToggle` and `InvitationInteractive` are client
components layered over it, each rendering nothing on the server. That split is how PRD §9.2
("text is never gated behind JavaScript") is satisfied, and every decision below preserves it.

Three current facts drive the scope: `admin_events.py` has no create or delete route and
`uq_event_wedding_type` forbids a second event of a type, so events come only from `seed.py`;
guests are unique per *wedding* (`uq_guest_wedding_phone`, `uq_guest_wedding_email`) and reach
events through `invitation` rows; and `InvitationInteractive` renders a four-state machine
(`idle → form → success | declined`) inline in the page flow.

Constraints that bind: 800KB initial transfer and LCP under 2.5s on 3G (the card becomes the
LCP element); Bangla and English (D13); guest PII crosses into client components only through
explicit view models (D8); the API authorizes on its own authority (D7); single VPS with
Docker Compose (D14).

## Goals / Non-Goals

**Goals:**
- A card that a designer uploads and publishes with no engineer and no deploy.
- A card that animates and reads correctly with JavaScript disabled.
- Events an admin creates, each owning its guest list and its card.
- One RSVP implementation presenting as a drawer or a sheet without duplicated logic and
  without server/client rendering disagreement.

**Non-Goals:**
- The `layered` manifest renderer and the `component` in-repo registry. Both declared,
  neither implemented.
- Any in-app design tool — no canvas, no drag-and-drop, no layer panel.
- Rendering the card to a downloadable image.
- Object storage. Filesystem, behind a seam.
- Multi-tenancy.
- Reconstructing a person's identity across events. Explicitly given up; see D11.

## Decisions

### D1 — Renderers, and why `html` replaced `component`
`event_card_design.renderer` carries four values; two are implemented.

- **`html`** — an uploaded, self-contained HTML document. The primary path.
- **`image`** — a single flat artwork file. The rush path.
- **`component`** (in-repo React template registry) and **`layered`** (manifest with
  positioned slots) remain declared and unimplemented.

*Rationale:* the registry was designed when the assumption was that a designer's file gets
ported into the repo. That assumption made a deploy a prerequisite for every new layout,
which is precisely the bottleneck this change exists to remove. Uploading the authored file
removes the engineer from the loop entirely.

*What the registry would still have bought, and we are giving up:* compile-time checking of
card markup, one component shared by many customers so a rendering fix lands once, and git
history on the designs. Accepted — at bespoke-per-customer volume a shared component has no
one to share with. `component` stays in the enum so a future catalogue tier is additive.

### D2 — Fixed content, which removes the whole templating problem
The card carries no dynamic data. Couple names, date and venue are typed into the design by
the designer, because that file serves exactly one event.

*Consequences, all favourable:* no template language, no slot mechanism, no data binding, and
**no injection surface at all** — nothing is interpolated into the markup, so a guest name can
never reach it. One card is byte-identical for every guest, so it caches hard.

*Consequence that is not favourable:* the date and venue are baked in, so changing a published
event's date makes the live card wrong. Handled as a warning rather than machinery (see the
stale-card requirement) because the alternative — slots for date and venue — reintroduces the
templating this decision deletes, and constrains how a designer may typeset a date.

### D3 — Rendered in a declarative shadow root
The card is emitted by a server component as `<template shadowrootmode="open">` containing the
sanitised uploaded markup and its `<style>`.

*Rationale:* it is the only mechanism that satisfies all four constraints at once — CSS
isolation in both directions, server-rendered text in the initial HTML, no build step, and
inherited fonts and custom properties from the page. Declarative shadow DOM is supported in
Chrome 90+, Safari 16.4+ and Firefox 123+, which covers the low-end Android this product
targets.

*Alternatives considered:* injecting the markup into the page directly — CSS leaks both ways
and would need selector rewriting or `@scope`, which is not safe on the target browsers. An
iframe — isolates correctly but puts the text outside the document (breaking §9.2), needs
postMessage height negotiation, and separates the accessibility tree.

*Note:* isolation is a correctness property here, not a security boundary. The security
boundary is D4.

### D4 — Sanitise on upload, store the sanitised bytes
Uploaded HTML and SVG have scripting, `on*` handlers and external references removed before
anything is written. What is stored is what was cleaned; the original is not retained.

*Rationale:* designs are first-party today, so this is defence in depth — but it is the
boundary that has to exist *before* self-serve upload is ever considered, and sanitising at
write time means the read path is a straight file read with no per-request cost.

*Trade-off:* a designer whose card depended on a Google Fonts link or an inline script gets a
silently different result unless the rejection is explicit. It therefore names the offending
reference rather than quietly dropping it.

### D5 — Rewrite relative asset references at upload
An uploaded card referencing `border.svg` is injected into a page served from `/i/{token}`,
where that relative URL resolves to `/i/border.svg` and 404s. At upload the card and its
companion files are stored together and every relative reference is rewritten to the stored
content-addressed URL. A reference with no matching uploaded file blocks publication.

*Rationale:* this is the failure that would otherwise reach production as "the card works in
the designer's browser and is blank on the site", which is expensive to diagnose and trivial
to prevent.

### D6 — Filesystem media behind a one-method seam *(implemented)*
Artwork is written to `/srv/media/cards/{event_id}/{sha256[:16]}.{ext}` and served by Caddy
off a read-only mount with `Cache-Control: immutable` — never through FastAPI. Content
addressing means a changed file gets a new URL, so nothing needs cache invalidation. All
writes go through `app/services/media.py`; nothing else touches the media filesystem.

*Consequence, addressed:* media is state outside Postgres, so `make backup` archives `./media`
alongside the dump and `make restore` pairs them by timestamp.

*Two-tier size policy:* a single file is rejected above 3MB; a *design* whose files total more
than the page's 800KB budget is warned about before publishing but not blocked. The gap is
deliberate — a full-screen-viewer asset or a hi-DPI poster can legitimately exceed the budget
when it is not the LCP element, and a hard block there would refuse valid designs.

### D7 — Composition: three parts above the fold, detail below
`InvitationShell` is re-composed, not replaced: header (greeting), body (card), footer (CTA),
and then — below the call to action — the existing countdown, venue, directions, notes and
contact sections.

*Rationale:* the three-part structure has no slot for the detail sections, but they are P0 in
`add-rsvp-v1` and they are the accessible, translatable, machine-readable source of truth an
artwork card cannot be. After the CTA satisfies both.

### D8 — `invitation_type` on the guest, decoupled from `max_guests` *(implemented)*
`guest.invitation_type` (`single` | `family`) with `server_default='single'`, so existing rows
were valid the moment the migration ran.

**It selects copy only.** `invitation.max_guests` remains the sole party-size ceiling. A
`family` guest with one seat is a contradiction the admin form warns about rather than
resolves — auto-raising a ceiling would change a headcount as a side effect of an editorial
choice.

### D9 — Messages on the wedding, one JSONB, locale-keyed *(implemented)*
`wedding.invitation_messages` keyed `{locale}.{type}`, at most four short strings, each capped
at 100 characters after normalisation, markup stripped, newlines collapsed. Missing keys fall
back to built-in defaults resolved server-side, so an empty greeting is unreachable.

*Caveat that will bite:* 100 characters is calibrated on English. Bangla says the same thing in
fewer characters but wider glyphs, so an at-limit Bangla message can overflow a line an
at-limit English one does not. Verified with a full-length Bangla message specifically.

### D10 — One RSVP surface, presentation chosen by CSS
A single component holds the dialog semantics, focus trap, Escape, scroll lock and the
existing form. Drawer versus sheet is decided by a CSS media query — position, size, transform
origin, entry keyframe — not by measuring the viewport in JavaScript, which has no viewport on
the server and would either flash the wrong presentation or force the surface client-only.

The existing stage machine is preserved; only `form` changes where it renders.

### D11 — Guests scoped to one event
`guest.event_id` is added; uniqueness moves from `(wedding_id, phone|email)` to
`(event_id, phone|email)`; a guest holds exactly one invitation, to its own event.

*This is an owner decision taken against the recommendation, re-confirmed after the costs were
stated.* Recording them here so they are found on purpose rather than by surprise:

- Duplicate detection, including CSV import, is per-event. Three rows for one person are not
  detectably the same person.
- `do_not_contact` and `email_invalid` are per-event. Unsubscribing from one event does not
  stop another. **This is a consent exposure**, mitigated only by unsubscribe copy naming the
  event it covers — see the per-event suppression requirement.
- Any total spanning events counts records, not people, and must be labelled as such.
- Correcting a contact detail is one edit per event.
- The multi-event switcher (FR-1.10) loses its basis and is removed.

*Migration:* an existing guest holding invitations to several events must become one record
per event. Pre-launch, with only seed data, re-seeding is the practical path and the migration
carries a split for correctness rather than for real rows.

### D12 — Events created, not seeded
`uq_event_wedding_type` is dropped so a wedding may hold any number of events of a type. An
event is created with a type, a name, a date and a venue; its slug is derived from the name
and de-duplicated on collision, and is stable across renames so shared links keep working.

*Rationale:* the constraint encoded "this product serves one wedding with exactly three
ceremonies". Card-per-event and guest-per-event both make the event the organising unit, and
an organising unit you cannot create is not one.

## Risks / Trade-offs

- [Per-event suppression lets an unsubscribed person keep receiving mail about other events]
  → Unsubscribe copy states which event it covers. This is a mitigation, not a fix; the fix
  was rejected with D11. Revisit before any bulk send to a list spanning events.
- [A designer uploads a 4MB PNG and the page misses its LCP budget] → Hard reject at 3MB per
  file, total design weight shown before publishing.
- [An uploaded card renders blank because its assets 404] → D5 rewrites references at upload
  and blocks publication on a missing companion file.
- [A published card shows a date the event no longer has] → Warning on event date and venue
  change; accepted consequence of D2.
- [Declarative shadow DOM unsupported on a guest's browser] → Content is still in the markup;
  it renders unstyled-but-legible rather than absent. Verified against the oldest target.
- [A full-length Bangla greeting overflows a header verified only in English] → Both locales at
  full character count in the responsive matrix, at 320px specifically.
- [Deleting an event destroys guests and responses irreversibly] → Confirmation states the
  count of guests and responses being destroyed.
- [Re-composing `InvitationShell` risks regressing shipped P0 behaviour] → Re-composed rather
  than rewritten; deadline, already-responded, declined, cancelled and closed states keep
  their logic and are covered by route-contract tests before the layout changes.

## Migration Plan

Migration one — **already applied** (`9a4c17be2d05`): `event_card_design`,
`guest.invitation_type`, `wedding.invitation_messages`.

Migration two — new: add `html` to the renderer enum; add `guest.event_id`; replace the
per-wedding guest unique indexes with per-event ones; drop `uq_event_wedding_type`; split any
guest holding invitations to several events into one record per event.

Deploy order: migrations → api → web. Media volume and Caddy `/media` handler are in place.

Rollback: migration one is reversible and additive. Migration two is **one-way as soon as a
guest is actually split** — verified, not assumed. Splitting leaves two records sharing a
phone within one wedding, which the old per-wedding unique indexes forbid, so the downgrade
cannot restore even the schema; it aborts on a duplicate key. The downgrade therefore checks
for such duplicates up front and refuses with an explanation pointing at the backup, rather
than failing with an opaque constraint error. A downgrade run before any split still works.
Restoring from the backup taken immediately before the migration is the only route back.

Feature exposure: a card appears only once a design is published, so the composition and the
card can ship in either order.

## Open Questions

- Whether `image` cards should get pinch-zoom in the full-screen viewer or only tap-to-expand.
  Depends on how legible the first real flat card is at 360px.
- Whether a future catalogue tier wants the `component` registry built after all. Answerable
  once several customers have been onboarded and it is clear how much designs actually repeat.
