# Tasks — batched invitation send from the guest list

Decision references are to this change's `design.md` (D1–D12) unless prefixed otherwise.
`add-guest-invitation-send` D-numbers are cited with that prefix.

## 1. Schema

- [x] 1.1 Add `batch_id` (uuid, nullable) to `MessageJob` in `api/app/models/messaging.py`
      with an index, and a comment naming D5 — what it groups and why it is nullable.
- [x] 1.2 Write one additive Alembic revision for 1.1, with a docstring stating why every
      existing row reads correctly as "no batch" and what the downgrade loses.
- [x] 1.3 `make migrate`, then confirm `alembic upgrade head` → `downgrade -1` → `upgrade`
      runs clean against a real Postgres.

## 2. Composition and placeholders

- [x] 2.1 Add `messaging.compose_bulk(event, wedding, *, locale, invitation_type)` returning
      subject, header, body and footer for one pane — body from `greeting.resolve(event…,
      wedding…)` for that type and locale, followed by the `{invitation_link}` placeholder,
      header carrying `{guest_name}` (D3, D11). No guest is involved.
- [x] 2.2 Assert by test that `compose_bulk` and `compose_manual` produce the same invitation
      sentence for the same event, locale and type — the two must not drift.
- [x] 2.3 Add `messaging.PLACEHOLDERS` (the closed set: `guest_name`, `invitation_link`) and
      `messaging.validate_template(subject, header, body, footer)` raising a named error for
      an unknown placeholder and for a body missing `{invitation_link}` (D4).
- [x] 2.4 Add `messaging.render_for_guest(parts, *, guest_name, invite_url)` producing the
      finished subject and the assembled body, reusing `messaging.assemble` so a batch and a
      single send join the three parts identically.
- [x] 2.5 Unit tests: single vs family body per locale; event override beating wedding-wide;
      built-in default when neither is set; unknown placeholder rejected by name; missing link
      placeholder rejected; two guests rendering to two different names and two different
      URLs; literal braces that are not placeholders rejected rather than passed through.

## 3. Batch enqueue and dispatch

- [x] 3.1 Add `messaging.batch_idempotency_key(invitation_id, batch_id, channel)` producing
      `{invitation_id}:batch-{batch_id}:{channel}`, leaving the scheduled, `direct` and
      `manual` forms byte-identical (D5).
- [x] 3.2 Teach `messaging.enqueue` to accept `batch_id`, defaulting to None for every
      existing caller.
- [x] 3.3 Add `messaging.enqueue_batch(session, *, invitations, parts_by_locale_and_type,
      batch_id, override_quiet_hours, sent_by_admin_id)` — filters suppressed guests, renders
      per recipient, inserts the jobs, and returns queued count plus the exclusions by reason
      (D3, D10). One insert statement, not one per guest.
- [x] 3.4 In `process_job`, treat a job carrying stored `body_text` as never requiring an
      accepted invitation, whoever claims it, and leave the template path untouched (D8).
- [x] 3.5 Add `messaging.dispatch_batch(batch_id)` — claim and send that batch's jobs through
      the existing `claim_due_jobs`/`process_job` path, for the API's background task to call
      after commit (D6).
- [x] 3.6 Add `messaging.batch_progress(session, batch_id)` returning counts by status plus
      the failed and skipped jobs with guest name and reason (D7).
- [x] 3.7 Unit tests: the same batch id inserted twice yields one job per invitation; two
      different batch ids to one invitation yield two jobs; a scheduled wave planned twice
      still yields one job; suppressed guests get no job and are reported; a stored-content
      job claimed by the worker's default `audience_accepted=True` still sends to a *pending*
      invitation (D8) while a reminder job to a pending invitation still skips.

## 4. API endpoints

- [x] 4.1 `GET /api/admin/events/{event_id}/guest-ids` behind `require(Action.SEND_MESSAGES)`,
      taking the same filters as the guest list and returning matching guest ids; refuse with
      a clear error above the 2,000 cap rather than truncating (D2).
- [x] 4.2 `POST /api/admin/events/{event_id}/invitations/compose` behind the same action,
      taking `guest_ids`, returning a composition pair per locale present in the selection,
      per-type deliverable counts, and the exclusion breakdown by reason (D1, D10, D11).
- [x] 4.3 Reject any guest id not belonging to the event in the route, in both 4.2 and 4.4,
      before any other work (D1).
- [x] 4.4 `POST /api/admin/events/{event_id}/invitations/send-batch` taking `batch_id`,
      `guest_ids`, the edited parts per locale and type, and `confirmed_quiet_hours`;
      validating templates via 2.3 and rejecting an empty subject or body for a type that has
      recipients.
- [x] 4.5 Return the already-recorded batch, rather than a second one, when a `batch_id` that
      already has jobs is submitted again (D5).
- [x] 4.6 Reject a send landing in quiet hours with `409` and the local time when
      `confirmed_quiet_hours` is absent; insert every job with `override_quiet_hours=True`
      when it is present (D9).
- [x] 4.7 Refuse the batch with a message naming the missing setting when no provider API key
      is configured and dry-run is off — before any row is inserted.
- [x] 4.8 Insert, commit, record one `audit.Actions.MESSAGE_SEND` row for the batch with its
      event, counts and batch id, then schedule `dispatch_batch` as a `BackgroundTask` and
      return (D6).
- [x] 4.9 `GET /api/admin/send-batches/{batch_id}` behind the same action, returning progress
      per D7, plus a `dry_run` flag so the modal can report a rehearsal honestly.
- [x] 4.10 Route tests: each role against all four endpoints (viewer refused, co-host allowed);
      a guest id from another event rejected; suppression excluded and counted; quiet-hours
      409 then confirmed success; duplicate `batch_id` returning the first batch; unknown
      event and unknown batch → 404; dry-run reported as a rehearsal.
- [x] 4.11 `make client` and commit the regenerated `web/lib/api/schema.d.ts` (CI fails on a
      stale client).

## 5. Guest list selection

- [x] 5.1 Add selection state to `GuestsView.tsx` keyed by guest id, surviving paging and
      cleared by any change to search, event, status, side or tag (D12).
- [x] 5.2 Add a checkbox column with a per-row control whose accessible name identifies the
      guest, and a header control that selects and clears the displayed rows.
- [x] 5.3 Show the select-all-matching banner only when the filters match more guests than are
      displayed; resolve it through the guest-ids endpoint and state the true total (D2).
- [x] 5.4 Add the **Send invitations** toolbar button beside Export CSV, Import CSV and Add
      guest — gated on `canSend`, disabled with the reason until an event is chosen and at
      least one guest is selected, and naming the selected count when enabled.
- [x] 5.5 Keyboard and screen-reader pass over selection: every checkbox focusable and named,
      the count announced as it changes, the disabled button's reason announced.

## 6. Send-invitations modal

- [x] 6.1 Build `web/components/admin/BulkInviteModal.tsx`: loads the compositions on open,
      renders the single and family panes side by side as labelled regions, each with editable
      subject, header, body and footer and its own recipient count.
- [x] 6.2 Render a pane with no selected guests of its type as visibly inert rather than
      hiding it, so the modal always tells the same two-sided story.
- [x] 6.3 Show the exclusion summary — how many will not be emailed, broken down by no
      address, opted out and hard-bounced — and disable Send when nobody is left (D10).
- [x] 6.4 Show the locale switch only when the selection spans locales, and require every
      locale's pair to be valid before Send (D11).
- [x] 6.5 Generate the `batch_id` in the browser when the modal opens, and reuse it for every
      Send attempt from that modal instance, so a double click cannot become two batches (D5).
- [x] 6.6 On a quiet-hours `409`, show a confirmation naming the local time and resend with
      `confirmed_quiet_hours` — the server decides, the browser only asks (D9).
- [x] 6.7 After a successful record, switch the modal to the progress view: poll the batch
      endpoint while anything is queued, show waiting/sent/failed/skipped, and list the failed
      and skipped guests with their reasons (D7).
- [x] 6.8 Report a dry run plainly as a rehearsal, never as a delivery.
- [x] 6.9 Keep guest data flowing through the existing view-model boundary — no guest object
      spread into a client component (CLAUDE.md PII rule).
- [x] 6.10 Explain the placeholders next to the editors, and surface a rejected placeholder as
      a field-level message naming it rather than a generic error (D4).

## 7. Verification

- [x] 7.1 `make lint` and `make test` clean.
- [x] 7.2 Walk the path against `make dev` with `DRY_RUN=true`: filter to an event, select a
      mixed set of single and family guests, edit both panes, send, and confirm the rehearsal
      log carries each guest's own name and own invite URL.
- [x] 7.3 Repeat with select-all-matching over a filter matching more than one page, and
      confirm the count sent equals the count stated on the button minus the exclusions.
- [x] 7.4 Confirm restart safety: record a batch, stop the API before the background task
      finishes, and confirm the worker completes the remaining jobs rather than skipping them
      (D8) — this is the path most likely to be missed.
- [x] 7.5 Confirm the message log lists every message in the batch with its status, and that
      the log screen still does not render `body_text` (D3).
- [x] 7.6 Confirm one audit row per batch, carrying its counts, not one per recipient.
- [x] 7.7 Confirm suppression: a guest who unsubscribes between compose and send receives
      nothing, and their job records why.
- [x] 7.8 `make walkthrough` still passes end to end.

## 8. Documentation

- [x] 8.1 Update `docs/USER-GUIDE.md` — sending a wave from the guest list beside the
      one-guest path: selecting, the two previews, what the placeholders do, that suppression
      cannot be overridden, and that a late-night batch warns before going out.
- [x] 8.2 Note in `CLAUDE.md` that `message_job.batch_id` groups one admin's batched send and
      is its duplicate boundary, that a stored-content job never requires an accepted
      invitation, and that `body_text` now holds guest text at wave scale — the rule against
      rendering it into the message log matters more, not less.
