# Proposal: add-event-invitation-card

## Why

`/i/{token}` currently opens an envelope animation and then presents a stack of typographic
sections — hero, countdown, venue box, RSVP buttons. It reads as a functional web form. The
artefact a Bangladeshi family actually cares about is the **card**: the printed piece with the
gold border, the lotus motif and the script names that gets hand-delivered to relatives.

Three things block selling that as a service, and they only work together:

1. **No way to attach a design.** There is no card at all, and no place to put one.
2. **Events cannot be created.** `seed.py` writes exactly three, one per type, and a unique
   constraint forbids a second Mehedi. A customer cannot name their own events.
3. **Guests are not organised by event.** They are global to the wedding, so there is no
   "the guest list for Walima" to work from — which is how a host actually thinks.

Together these mean every new customer needs an engineer. This change makes a card
uploadable, an event creatable, and a guest list event-scoped, so onboarding a customer is
an admin sitting at a screen.

## What Changes

**The card is an uploaded HTML file, attached to an event**

- The designer builds a self-contained `card.html` (plus its artwork) in a plain browser and
  it is **uploaded through the admin** — no engineer, no deploy. What they preview is
  byte-for-byte what ships.
- **The card is fixed per event.** Couple names, date and venue are typed into the design by
  the designer, because that file serves exactly one event. Nothing is interpolated into it,
  so there is no template language, no slots, and no injection surface.
- It renders inside a **declarative shadow root**: server-rendered so its text is in the
  initial HTML (PRD §9.2), and CSS-isolated so the card and the page cannot disturb each
  other's styling.
- Relative asset references in the uploaded file (`src="border.svg"`) are **rewritten at
  upload time** to the stored content-addressed URLs, because the file is injected into a
  page served from a different path and would otherwise 404.
- `renderer` values: **`html`** (primary) and **`image`** (flat artwork, rush path).
  `component` and `layered` remain declared and unimplemented.
- All personalisation stays in the **header**, outside the card.

**Events become real records**

- Events are **created by admins**: pick one of the three types, give it a name, date and
  venue. **BREAKING:** the `uq_event_wedding_type` constraint is dropped, so a wedding may
  hold two receptions or three Mehedi nights if that is what happened.
- Slugs are derived from the name and de-duplicated automatically.
- The card upload lives on the event, and applies to every invitation under it.

**Guests belong to an event**

- **BREAKING:** a guest record is scoped to one event. Uniqueness moves from
  `(wedding, phone|email)` to `(event, phone|email)`, and guests are added from inside an
  event rather than globally.
- A person attending three ceremonies is therefore **three guest records**. This is a
  deliberate owner decision, taken with the costs below stated and re-confirmed.
- **BREAKING:** the multi-event switcher (FR-1.10, "You're also invited to Walima →") is
  **removed**. Nothing links a person's records across events any more, so it has no basis.

**The composition**

- Three parts: a compact transparent **header** carrying only the personal greeting, a
  **body** where the uploaded card dominates, a **footer** with a single Accept action.
  Countdown, venue, directions, calendar and host contact move below the call to action.
- The RSVP form opens on demand — a **right drawer** on wide screens, a **bottom sheet** on
  narrow ones — with focus trapping, Escape, scroll lock, safe-area insets and on-screen
  keyboard handling. Accept and Decline both survive unchanged.
- Guests gain an **invitation type** (`single` | `family`) selecting between two short
  greeting sentences, customer-editable per locale and capped at 100 characters.

## Capabilities

### New Capabilities

- `invitation-card`: The uploaded card — design record and versioning, the `html` and `image`
  renderers, shadow-root isolation, HTML/SVG sanitisation, asset URL rewriting, filesystem
  media storage and ingestion limits, the authoring contract, full-screen viewing, and the
  admin upload/preview/publish flow.
- `invitation-presentation`: The guest-facing composition — three-part structure and its
  visual priority, the personalised greeting with invitation type and editable messages, the
  responsive RSVP surface, and cross-viewport behaviour.
- `event-management`: Events as created records — type plus name, multiple events per type,
  slug derivation, the card attachment point, and event-scoped guest lists with per-event
  uniqueness and per-event contact suppression.

### Modified Capabilities

None expressible. `openspec/specs/` is empty — `add-rsvp-v1` has not been archived, so
`invitation-page`, `guest-management` and `csv-export` exist only as pending deltas with no
main spec to write against. This change contradicts several of their requirements; see
Impact for the reconciliation that must happen at archive.

## Impact

**Owner decisions recorded (2026-08-13):** designs are authored by us and uploaded, not
ported into the repo; the card is fixed per event and carries no per-guest data; media lives
on the local filesystem; events are admin-created with a name; guest records are scoped and
unique per event; share-as-image remains out of scope.

**Accepted losses from event-scoped guests.** Stated when the decision was made, restated
here so nobody rediscovers them as bugs:

- CSV import can no longer tell that three rows are one person; duplicate detection is
  per-event only.
- `do_not_contact` and `email_invalid` become per-event. Unsubscribing from Walima does not
  stop Mehedi email. **This is a consent and deliverability exposure**, and the unsubscribe
  copy must say which event it applies to.
- Dashboard totals count guest *records*, not people. 500 people across three events reads
  as 1,500. Per-event counts remain correct; cross-event unique headcount is unavailable.
- Correcting a phone number is one edit per event the person appears in.

- **Database**: already applied and kept as-is — `event_card_design`, `guest.invitation_type`,
  `wedding.invitation_messages`. New in a second migration: `guest.event_id` with per-event
  unique indexes replacing the per-wedding ones, the `html` renderer enum value, and dropping
  `uq_event_wedding_type`. Existing guests holding invitations to several events are split
  into one record per event; pre-launch the practical path is to re-seed.
- **API**: event create/delete with slug derivation; guest routes scoped by event; card design
  CRUD, upload, publish and rollback; the published design folded into the invitation and
  open-event payloads. New Pydantic models flow into `web/lib/api/schema.d.ts` — `make client`
  or CI's freshness check fails.
- **Web**: `InvitationShell` re-composed; a shadow-root card renderer; a responsive RSVP
  surface replacing the inline form stage; event create and card upload screens; the guest
  list moved under an event; the multi-event switcher removed.
- **Infra**: `./media` volume served by Caddy, and `make backup` covering it — both already in
  place.
- **Fonts**: a self-hosted subset Bangla webfont; unsubset Noto Serif Bengali is ~250KB against
  an 800KB budget.
- **Performance**: the card becomes the LCP element. The existing budget applies to it
  directly and a careless artwork export is what will break it first.
- **Overlap to reconcile at archive**: `add-rsvp-v1`'s `invitation-page` spec requires the
  multi-event switcher (FR-1.10) which this change removes, and its task 6.1 (hand-built
  themed templates) and 6.4 (media upload) are superseded and absorbed. Its
  `guest-management` spec assumes wedding-scoped guests and cross-event duplicate merging;
  both are now event-scoped.
- **Product-shape flag**: the schema remains single-wedding (`event.slug` globally unique, one
  `wedding` row assumed). Nothing here forces multi-tenancy, but "design cards for customers
  on demand" is a multi-tenant statement and that conversation is overdue.
