docs: HANDOFF pass-56, Phase 4 notification breadth + migration 047
Adds an ARCHITECTURE section "Notification breadth: bookings, classes, forms" describing each new event type, the triggering action, the recipient rule, and the dedupe-key scheme. Updates Known Limitations #5 to reflect that booking + class + form notifications are no longer "not wired yet." API_REFERENCE: extends the Transactional notifications section with the shared enqueue helper, every new event type, its trigger and recipient, and the new members_with_permission RPC used for the forms.manage fan-out. DATABASE_SCHEMA + DB_SCHEMA_MAP: adds the migration 047 row and the new helper function to the helper-functions list. HANDOFF: pass-56 entry covers the four locked design forks, the four sub-phase commits, test growth (532 -> 565, +33 cases), state (local, inert post-deploy until owner provisions Resend + CRON_SECRET), the known fan-out volume tradeoff, and the remaining backlog.
This commit is contained in:
@@ -962,9 +962,13 @@ Per-space OWN Stripe account (NOT Connect). Pure mapping in `lib/stripe-logic.ts
|
||||
- `getMyBilling()` — the caller's own billing status (service client; `member_billing` is admin-RLS, mirrors `getMyCards`). `listMemberBilling()` — admin/board/treasurer dues view.
|
||||
- Webhook `POST /api/stripe/webhook/[space]` (nodejs, force-dynamic, unauthenticated by design — verify is signature-based; `proxy.ts` whitelists `/api/stripe/webhook` so the unauthenticated call is not redirected to `/login`): raw `req.text()` body, loads THAT space's secret + signing secret from the vault, `constructEvent`, idempotency via `stripe_webhook_events` PK (replay→200). On `checkout.session.completed` / `customer.subscription.*` / `invoice.paid|payment_succeeded`: upsert `member_billing`, record a `stripe` `payments` row, and map `duesMemberStatus`+`graceExceeded` onto `space_members.status` (only `current`↔`late` — never auto-inactive or auto-approve `unverified`; `last_paid_at` on paid). Member resolved by metadata then customer id, always space-scoped. It also enqueues dues-lifecycle notifications (see below). Bad signature/config → 400; handler error → 500 (Stripe retries; idempotency-safe).
|
||||
|
||||
### Transactional notifications: `notifications.ts` + `lib/email/*` + dispatcher (migration 041)
|
||||
Outbox + dispatcher; the webhook never sends inline. Pure render + dedupe in `lib/notifications-logic.ts` (`renderDuesEmail`, `duesDedupeKey`, `isTerminalAttempt`, `MAX_NOTIFICATION_ATTEMPTS`) is unit-tested. `lib/email/send.ts` (`sendEmail`) is the transport seam over Resend's HTTP API (no SDK; `fetch`), returning `{ ok, id } | { ok:false, error, retryable }`; unset `RESEND_API_KEY`/`EMAIL_FROM` is a non-retryable no-op.
|
||||
### Transactional notifications: `notifications.ts` + `lib/email/*` + dispatcher (migrations 041, 047)
|
||||
Outbox + dispatcher; the webhook never sends inline. Pure render + dedupe in `lib/notifications-logic.ts` (`renderDuesEmail`, `duesDedupeKey`, `isTerminalAttempt`, `MAX_NOTIFICATION_ATTEMPTS`, plus the Phase 4 additions below) is unit-tested. `lib/email/send.ts` (`sendEmail`) is the transport seam over Resend's HTTP API (no SDK; `fetch`), returning `{ ok, id } | { ok:false, error, retryable }`; unset `RESEND_API_KEY`/`EMAIL_FROM` is a non-retryable no-op. A shared best-effort helper `lib/notifications/enqueue.ts` (`resolveMemberContact`, `enqueueNotification`, `getSpaceName`, `buildManageUrl`) is the only enqueue path; it never throws into the calling action.
|
||||
- The Stripe webhook enqueues into `notifications` (`ignoreDuplicates` on the `(space_id, dedupe_key)` unique index, so an event replay is a no-op): `invoice.paid` → `dues_renewed`, `invoice.payment_failed` → `dues_payment_failed`, transition to `late` (in `applySubscription`) → `dues_lapsed`. Recipient + space name resolved server-side; never sends inline.
|
||||
- Phase 4 event types (no schema change to `notifications`; the `type` column is `text`):
|
||||
- `reserveEquipment` → `booking_confirmed` (always, to the booked-for member, so a manager booking on someone's behalf still emails the target). `cancelReservation` → `booking_cancelled` ONLY when the actor is not the affected member (self-cancels stay silent).
|
||||
- `signUpForClass` → `class_signup_registered` or `class_signup_waitlisted` (picked from `class_signup_tx`'s returned status, so a manager signing someone up still emails the target with the right copy). `cancelMySignup` → `class_signup_promoted` to whoever `class_cancel_tx` returned as `promoted_id`. `updateSession` setting `status='cancelled'` → `class_session_cancelled` fanned out to every still-active signup (dedupe by `(session, member)`).
|
||||
- `submitForm` → `form_submission_received` ONLY when the submitter is authenticated (recipient = verified `user.email`, never the typed body email; anonymous public submissions skip the confirmation because confirming to a typed address is a victim-spam vector). Plus `form_submission_admin` fanned out to every member who holds `forms.manage` (link goes to `/forms/<id>/results`; dedupe by `(submission, admin)`). The fan-out uses the new `members_with_permission(sid, perm)` SQL helper (migration 047), the inverted set-returning form of `user_has_permission`.
|
||||
- Dispatcher `POST /api/cron/notifications` (nodejs, force-dynamic; constant-time `CRON_SECRET` bearer; 503 if unset, 401 on mismatch; `proxy.ts` whitelists `/api/cron`). Drains ≤20 oldest `pending` email rows with `attempts < MAX`, sends with the row id as Resend `Idempotency-Key` (overlap-safe), ~4.5/s spacing. Success → `sent`+`sent_at`; failure → `attempts++`, `pending` if retryable and budget remains else `failed`. Returns `{ scanned, sent, failed, retried }`. Hit once a minute by the droplet crontab.
|
||||
- `getMyNotifications()` — the caller's own last 15 notifications (service client; `notifications` is admin-RLS, mirrors `getMyBilling`). Surfaced read-only on `/me`.
|
||||
- `getMyPayments()` — the caller's own last 50 payments (service client; `payments` SELECT is treasurer-scoped, so the self-view is strictly scoped to the caller's `space_id` + `member_id`, same convention as `getMyBilling`). Surfaced read-only in the `/me` Membership tab.
|
||||
|
||||
Reference in New Issue
Block a user