# Design — batched invitation send from the guest list

## Context

See `proposal.md` — Why. The machinery this builds on already exists and mostly does not need
changing:

* `message_job` is the queue. A job may carry its own `subject`/`body_text`, and
  `process_job` prefers that over rendering a template (`add-guest-invitation-send` D3). A
  batched send is that same stored-content job, many times over.
* `messaging.compose_manual` composes one invitation's default message, resolving the body
  through `greeting.resolve(event.invitation_messages, wedding.invitation_messages, …)` for
  the guest's locale and invitation type. The two panes are that resolution called twice, for
  the two types, with the guest-specific parts left as placeholders.
* `manual_idempotency_key` carries a random nonce so a repeat manual send is delivered rather
  than silently deduplicated (D2 there). A batch needs the same repeatability *between*
  batches and strict deduplication *within* one.
* A guest belongs to exactly one event and holds exactly one invitation (CLAUDE.md, D11), so
  "selected guests" and "selected invitations" are the same set, and the event is not a
  choice the batch has to make per row.

Three constraints shape everything below. The largest realistic batch is the whole guest list
(~1,500). The sender's token bucket runs at 10/s, so a full wave takes minutes — far longer
than one HTTP request. And the worker's 30-second sender poll is the process that owns
retries and stuck jobs, so anything the API sends itself must be safe to have the worker
finish.

## Goals / Non-Goals

**Goals**

* One approval moment: the exact text both halves of the list will receive, on screen, before
  anything is queued.
* A batch behaves like every other send once recorded — same queue, same retries, same log,
  same webhooks — so no second delivery path exists to keep correct.
* A batch covering 1,500 guests records in one interactive request and reports its own
  progress afterwards.

**Non-Goals**

* Replacing `/admin/send` (the audience-and-template bulk screen). It stays as it is; this is
  a different act, from a different screen, with different inputs.
* Scheduling a batch for later. Every batch is now-or-nothing; scheduled waves are the
  reminder planner's job.
* Saving an edited composition back to the event. Edits are per batch, exactly as they are
  per send today.
* Channels other than email, and per-guest text within an invitation type.

## Decisions

### D1 — The composer is scoped to one event, and the button insists on it

The two panes exist because an event has two invitation messages. A selection spanning events
has no single pair to show, so the toolbar button is disabled until an event filter is chosen
— the treatment Import CSV and Add guest already carry on this screen for the same reason.
The event travels in the route (`/admin/events/{event_id}/…`), so a request body cannot
contradict it, and the API rejects any guest id that does not belong to that event.

*Alternative considered:* one pane pair per event in the selection. Three events becomes six
editable panes and the "approve at a glance" property — the entire reason for this screen —
is gone. *Also considered:* seed from the first selected guest's event and send that text to
everyone. Silently mails the Walima wording to Mehedi guests.

### D2 — The selection travels as explicit guest ids, including "select all matching"

The send request carries the ids it will mail. "Select all N matching" is resolved in the
browser first, by asking the API for the ids matching the current filters
(`GET /admin/events/{id}/guest-ids`), and then behaves identically to ticking them by hand.

The point is that the set the admin approved is the set that is sent. A filter-shaped
selection re-evaluated at send time can grow between the count on the button and the messages
going out — an import finishing in another tab is enough. Explicit ids cannot. 1,500 uuids is
~55 KB of request body, which is not a problem worth designing around.

The id endpoint is capped (2,000) and refuses rather than truncating: a silently short list
would send to some of the people the admin asked for and report success.

### D3 — The edited text is a template; substitution happens once, at enqueue

`{guest_name}` and `{invitation_link}` are substituted per recipient as the jobs are
inserted, and each job stores its own finished `subject`/`body_text`. `process_job` is
untouched — it already prefers stored content, already appends the unsubscribe line to it,
and already wraps it as HTML.

*Alternative considered:* store the template once and substitute at send time. Cheaper in
storage, but it needs a new place to keep the template, a new column pointing at it, and a
new branch in the send path — three new things, to save space that does not matter at this
scale (1,500 × ~600 bytes ≈ 1 MB per batch). Storing the finished text also means the job row
*is* the record of what was sent, which is what the message log and any later dispute need.

The storage-side consequence carries over from `add-guest-invitation-send` D3 and gets worse
by three orders of magnitude: `body_text` now holds 1,500 guests' names and tokenised URLs.
It must never be rendered into the message log.

### D4 — Placeholders are a closed set, validated before anything is queued

Exactly two are recognised. A body missing `{invitation_link}` is refused — an invitation with
no link is not an invitation. Any other `{…}` sequence is refused, naming it, rather than
delivered literally: a mistyped `{guset_name}` reaching 300 inboxes is not recoverable, and
the admin who typed it is the only person who can fix it. Validation runs on the send request,
before the first insert, so the batch is all-or-nothing on this.

Braces that are not placeholders are rare enough in invitation copy that refusing them is
cheaper than an escaping convention nobody will remember.

### D5 — `batch_id` is a column, supplied by the client, and is the duplicate boundary

`message_job` gains a nullable, indexed `batch_id`. Each job's idempotency key becomes
`{invitation_id}:batch-{batch_id}:{channel}`.

This gives both behaviours the spec asks for from one mechanism. Within a batch, the unique
constraint on `idempotency_key` makes a double-clicked Send, a retried request or a browser
refresh insert nothing new — the second request finds the batch already recorded and returns
it. Between batches, a fresh id means a fresh key, so a deliberate resend is delivered, which
is the same reasoning as the single-send nonce.

Client-supplied rather than server-generated because the id has to exist *before* the request
that could be duplicated. A server-generated id makes the second click a second batch.

`batch_id` is also what makes progress a single indexed query, and what lets one audit row
stand for the whole send. Nullable because every job that predates this change has no batch,
and every scheduled job still will.

### D6 — Recording is decoupled from delivery

The request inserts the jobs, commits, records one audit row, and returns. Delivery is kicked
off in a FastAPI `BackgroundTask` after the commit — the same shape the RSVP confirmation path
already uses — and the worker's 30-second poll is the safety net for whatever the background
task does not finish or an API restart interrupts. `FOR UPDATE SKIP LOCKED` is what makes it
safe for both to be working the same batch.

*Alternative considered:* send inline like the single-guest endpoint. At 10/s a 400-guest
batch is 40 seconds of held request and a proxy timeout; at 1,500 it is not close.
*Also considered:* queue only, and let the poll find it. Correct but adds up to 30 seconds of
nothing happening after a click, which reads as a failure.

### D7 — Progress is polled, not pushed

`GET /api/admin/send-batches/{batch_id}` returns counts by status plus the guests that failed
or were skipped with their reasons. The modal polls it every couple of seconds while anything
is still queued, and stops when nothing is.

No SSE and no websocket: this is one admin watching one batch for a few minutes, and adding a
streaming transport to a stack that has none is a large amount of new failure surface for a
progress bar. Polling also means progress survives closing and reopening the modal, which the
spec requires and a push channel would have to reimplement.

### D8 — A job carrying its own content never requires an accepted invitation

`process_job` takes `audience_accepted`, and `_skip_reason` skips a job whose invitation is
not `accepted` when it is set. The worker's poll passes the default, `True`. That is right for
reminder waves — it is what makes a cancellation retroactive — and wrong for an invitation,
which by definition goes to someone who has not answered.

Today the single-send endpoint dodges this by passing `audience_accepted=False` itself, which
holds only because it sends the job inline. A batched job is claimed by the worker on retry
and would be skipped there as "invitation is no longer accepted".

So the rule moves onto the job: `process_job` treats a job with stored `body_text` as never
requiring acceptance, whoever claims it. Nothing else changes — reminders carry no stored
body, so their behaviour is byte-identical.

Worth stating plainly, because it is the same hole one layer over: the existing
`/admin/send` invite path enqueues template jobs with no stored body and lets the worker send
them, so it skips every pending guest for this reason. That is a pre-existing defect in a
screen this change does not touch, and it is not fixed here.

### D9 — Quiet hours are decided once, for the batch

The send request is refused with a `409` naming the local time unless it carries the
confirmation flag; with it, every job in the batch is inserted with `override_quiet_hours`
set. The admin is asked once, not 1,500 times, and the flag rides on the row so a job the
worker picks up at 23:50 still goes out rather than deferring to 08:00 and contradicting what
the admin was told.

Automated waves never set the flag, so FR-6.9 is unchanged for everything planned.

### D10 — Suppression is filtered before insert, and re-checked at send

Guests with no address, a hard bounce or an opt-out for this event get no job at all. They are
counted and broken down by reason in the compose response and again in the send response, so
"40 selected, 37 will be emailed" is visible before the click. `process_job` re-checks, which
is what catches an unsubscribe that lands between recording and sending.

No flag makes a suppressed guest sendable. This is the one thing on the screen that
confirmation does not unlock.

### D11 — One pane pair per locale actually present in the selection

Composition needs no guest — the guest-specific parts are placeholders — so it is a function
of event, locale and invitation type. The composer requests a pair for each locale present
among the selected guests, and each guest is mailed the pair for their own `preferred_locale`.

With a single-locale selection, which is the ordinary case, that is exactly the two panes the
screen is designed around and no locale control appears at all. With both present, a switch
above the panes moves between the two pairs, both of which must be valid before Send.

Four panes at once was rejected as unreadable; one pane pair for everyone was rejected because
it mails English to Bangla readers, which is precisely the cross-locale fallback
`greeting.resolve` refuses to do for the invitation page.

### D12 — Selection lives in the browser and is cleared by any filter change

Selection is component state keyed by guest id. It survives paging, because moving through
pages to tick more names is the point, and is emptied whenever a filter or the search term
changes, because a selection whose rows are no longer on screen cannot be verified before it
is sent. The button states the count for the same reason.

Nothing about the selection is persisted server-side: there is no state to expire, no
concurrent-edit question, and a reload starts clean, which is the safe direction.

## Risks / Trade-offs

* **"Select all matching" sends to rows the admin never read** → chosen deliberately over
  eight page visits for a 400-guest wave. Mitigated by stating the count on the button, the
  deliverable count and exclusion breakdown in the modal, and both full messages on screen
  before Send. The blast radius is a message, not a mutation.
* **A batch stores ~1 MB of guest names and tokenised URLs in `message_job`** → accepted (D3).
  The mitigation is the existing rule, restated in `CLAUDE.md`: `body_text` is never rendered
  into the message log. Any new screen reading `message_job` must be checked against this.
* **The API process and the worker both send, each with its own in-process 10/s bucket** →
  briefly up to 20/s to the provider. Under any realistic provider limit, and bounded by one
  batch at a time. If a second API replica is ever added, the bucket has to move to Postgres —
  the same note the existing bucket already carries.
* **Guests deleted or edited between compose and send** → the insert skips invitations that no
  longer exist and the send response reports the shortfall, so the counts the admin saw and
  the messages that went out are reconcilable rather than silently different.
* **A batch recorded, then the API restarts before the background task finishes** → the worker
  picks the remaining jobs up on its next poll; D8 is what stops it skipping them. This is the
  path most likely to be missed in testing, so it gets an explicit test.
* **Progress polling on a 1,500-job batch** → one indexed count query per poll every two
  seconds, by one admin. Negligible, and it stops when the batch is terminal.
* **A mid-batch provider outage** → jobs retry on the existing backoff and then fail; the
  progress view names the guests that failed, and resending to just those is a new, smaller
  batch, which the batch-per-send model already supports.

## Migration Plan

1. One additive Alembic revision: `message_job.batch_id` (uuid, nullable) plus an index on it.
   Every existing row reads correctly as "no batch". The downgrade drops the column, which
   loses the grouping of past batches; the jobs, their status and the message log survive.
2. Deploy API before web. The new endpoints are unused until the new client ships, and the
   guest list without them is exactly today's screen.
3. **Restart the worker with the API.** D8 lives in `process_job`, which both processes run
   from the same image — a worker left on the old code skips every batched invitation it
   claims as "invitation is no longer accepted", and does it silently. This was observed, not
   theorised: a dev worker that had been up since before the change skipped a whole batch
   that way. A normal compose deploy restarts both; a hot-reloaded API next to a long-running
   worker is the case that does not.
4. Rehearse on `make dev` with `DRY_RUN=true` against a seeded event before any real send:
   a batch of both invitation types, both locales, and a batch inside quiet hours.
5. Rollback is the migration downgrade plus the previous web image. No data written by this
   change is required by anything older.
