# Tasks — send an invitation to one guest

Decision references are to this change's `design.md` (D1–D12) unless prefixed otherwise.

## 1. Schema

- [x] 1.1 Add `subject`, `body_text` (text, nullable), `override_quiet_hours` (bool, not null,
      server default false) and `sent_by_admin_id` (uuid, nullable, FK `admin_user.id`
      `ON DELETE SET NULL`) to `MessageJob` in `api/app/models/messaging.py`, with comments
      naming D3 and D5.
- [x] 1.2 Add `host_email` (text, nullable) to `Wedding` in `api/app/models/wedding.py` (D9).
- [x] 1.3 Write one additive Alembic revision covering 1.1 and 1.2, with a docstring stating
      why every existing row reads correctly under the new code and what the downgrade loses.
- [x] 1.4 `make migrate` and confirm `alembic upgrade head` then `downgrade -1` then `upgrade`
      runs clean against a real Postgres.

## 2. Composition service

- [x] 2.1 Add `messaging.compose_manual(session, invitation)` returning subject, header, body,
      footer, invite URL, recipient address and locale — body from `greeting.resolve(event…,
      wedding…)` for the guest's `invitation_type` plus a blank line and the invite URL (D4).
- [x] 2.2 Localise the header and footer chrome (`Dear` / `প্রিয়`, `Regards` / `শুভেচ্ছান্তে`) and the
      default subject, keyed by the guest's `preferred_locale`; no cross-locale fallback.
- [x] 2.3 Add `messaging.assemble(header, body, footer)` producing the plain-text body that is
      stored and sent, so the compose endpoint and the send endpoint cannot disagree on it.
- [x] 2.4 Add `messaging.last_manual_send_at(session, invitation_id)` — `max(sent_at)` over
      that invitation's `sent` jobs (D8).
- [x] 2.5 Unit tests: single vs family body, event override beating wedding-wide, built-in
      default when neither is set, Bangla guest getting Bangla chrome and no English leak,
      invite URL present in every composition.

## 3. Send pipeline

- [x] 3.1 Extend `messaging.idempotency_key` with a `manual` form carrying a fresh
      `secrets.token_hex(8)`, leaving the scheduled and `direct` forms byte-identical (D2).
- [x] 3.2 Teach `messaging.enqueue` to accept `subject`, `body_text`, `override_quiet_hours`
      and `sent_by_admin_id`, defaulting to today's behaviour when they are not passed.
- [x] 3.3 In `process_job`, use the job's stored `subject`/`body_text` when present instead of
      loading a template, and skip `defer_past_quiet_hours` when `override_quiet_hours` is set
      (D3, D5). Both must be no-ops for every job that predates this change.
- [x] 3.4 Keep `templating.append_unsubscribe` on the stored-body path, and generate the HTML
      part with `templating.to_html` exactly as the template path does (D12).
- [x] 3.5 Add `reply_to: str | None` to `providers.SendRequest`, map it in
      `ResendEmailProvider.send`, and pass the wedding's `host_email` for every send — not
      manual sends only (D9).
- [x] 3.6 Raise a clear configuration error, naming the missing setting, when a send is
      attempted with no provider API key and dry-run off (spec: provider not configured).
- [x] 3.7 Unit tests: stored body wins over template; job without stored body renders from the
      template unchanged; `override_quiet_hours` sends at 23:30 while a job without it defers;
      unsubscribe appended to an edited body that omits it; two manual sends to one invitation
      produce two jobs while two identical scheduled plans still produce one.

## 4. API endpoints

- [x] 4.1 `GET /api/admin/invitations/{id}/message` behind `require(Action.SEND_MESSAGES)`,
      returning the composed parts plus `to_email`, `last_sent_at`, `blocked_reason` and
      `in_quiet_hours` (D4).
- [x] 4.2 `POST /api/admin/invitations/{id}/send` behind the same action, taking subject,
      header, body, footer and `confirmed_quiet_hours`; validating that subject and body are
      non-empty after trimming.
- [x] 4.3 Reject a suppressed recipient with `409` and a reason naming which of no-address,
      opted-out or hard-bounced applies — before any row is inserted (D6).
- [x] 4.4 Reject a send landing in quiet hours with `409` and the local time when
      `confirmed_quiet_hours` is absent; proceed with `override_quiet_hours=True` when it is
      present (D5).
- [x] 4.5 Insert the job, commit, then await `process_job` for that row and return its terminal
      status with `sent_at`, `error_message` and a `dry_run` flag (D7).
- [x] 4.6 Record `audit.Actions.MESSAGE_SEND` with the invitation id, event and acting admin.
- [x] 4.7 Route tests: each role against both endpoints (viewer refused, co-host allowed);
      suppression 409s; quiet-hours 409 then confirmed success; dry-run reported as a rehearsal
      rather than a delivery; unknown invitation id → 404.
- [x] 4.8 Add `host_email` to the wedding settings read and write payloads in
      `api/app/routers/admin_wedding.py`, validated as an email address when present.
- [x] 4.9 `make client` and commit the regenerated `web/lib/api/schema.d.ts` (CI fails on a
      stale client).

## 5. Guest list

- [x] 5.1 In `GuestsView.tsx`, render the guest name as text — remove the button, its click
      handler and its hover affordance (spec: the name is not a control).
- [x] 5.2 Add a **Send invitation** row action beside Edit and Remove, opening the same detail
      modal, gated on the send-messages permission and carrying an accessible name that
      identifies the guest.
- [x] 5.3 Thread the new permission into `GuestsView` from
      `web/app/admin/(protected)/guests/page.tsx` alongside the existing `can()` checks.
- [x] 5.4 Stop passing `canGenerateQr` into `GuestDrawer` (D10).

## 6. Guest detail modal

- [x] 6.1 Delete the QR block, its state, the `canGenerateQr` prop and the `downloadUrl.guestQr`
      import from `GuestDrawer.tsx`. Leave `QrView` and the endpoint alone (D10).
- [x] 6.2 Restructure the modal into headed, visually separated sections — details, then
      **Send via copying the link**, then **Send via messaging**, then the RSVP override — each
      an `<h3>` on its own surface, per D11.
- [x] 6.3 Build `web/components/admin/InviteCompose.tsx`: loads the composed message on open,
      renders subject / header / body / footer as editable fields, states the recipient
      address, and disables the send control with the reason shown when `blocked_reason` is set.
- [x] 6.4 Show "Last emailed {when}" when `last_sent_at` is present, and disable the send
      control while a send is in flight (D2, D8).
- [x] 6.5 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 (D5).
- [x] 6.6 Report the terminal outcome in place: sent with the time, failed with the reason,
      refused with the reason, or a plainly-labelled dry-run rehearsal.
- [x] 6.7 Keep the modal's guest data flowing through the existing view-model boundary — no
      guest object spread into a client component (CLAUDE.md PII rule).

## 7. Verification

- [x] 7.1 `make lint` and `make test` clean.
- [x] 7.2 Walk the path against `make dev` with `DRY_RUN=true`: open the guest list, use the row
      action, edit all four parts, send, and confirm the rehearsal log carries the edited text
      and the guest's real invite URL.
- [x] 7.3 Confirm the removals: the guest name does nothing when clicked, and no QR control
      remains in the modal while the per-event QR screen still produces per-guest artwork.
- [x] 7.4 Confirm the message log lists the manual send with its status, and that the log
      screen does not render `body_text` (D3).
- [x] 7.5 Keyboard and screen-reader pass over the modal: section headings in order, compose
      fields labelled, disabled send control's reason announced.
- [x] 7.6 `make walkthrough` still passes end to end.

## 8. Documentation

- [x] 8.1 Update `docs/USER-GUIDE.md` — inviting one guest is now a row action and a Send
      button, not copy-and-paste; state that suppression cannot be overridden and that a
      late-night send warns before going out.
- [x] 8.2 Note in `CLAUDE.md` that `message_job` may now carry its own content, that a stored
      body wins over the template, and that manual sends use a per-send idempotency key — the
      exactly-once guarantee for waves is unchanged.
