feat(notifications): P2e member self-view on /me + docs

getMyNotifications (service-client self-view, mirrors getMyBilling) +
a read-only Notifications section on /me. Docs updated in the same
change: ARCHITECTURE (Phase 2 subsystem + limitation #5), API_REFERENCE
(notifications section, fixed the stale no-auth-middleware claim),
DATABASE_SCHEMA + DB_SCHEMA_MAP (migration 041 + table), DEPLOYMENT
(env vars + dispatcher crontab), HANDOFF pass-45.
This commit is contained in:
Moheeb Zara
2026-05-17 04:58:46 -07:00
parent 75097a601c
commit 7eb83676e7
9 changed files with 138 additions and 4 deletions
+7 -1
View File
@@ -959,4 +959,10 @@ Per-space OWN Stripe account (NOT Connect). Pure mapping in `lib/stripe-logic.ts
- `startDuesCheckout()` — any member; resolves the caller + their tier server-side, ensures a Stripe Customer (reuse/create + `member_billing` upsert), returns a hosted Checkout Session URL in `subscription` mode with `client_reference_id` + metadata on the session AND `subscription_data.metadata`.
- `startBillingPortal()` — any member with a customer; returns a Stripe Billing Portal URL (self-serve card/plan/cancel; the space must activate the Portal in its Stripe Dashboard once).
- `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; no auth middleware in this app): 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. Bad signature/config → 400; handler error → 500 (Stripe retries; idempotency-safe).
- 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.
- 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.
- 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`.