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`.
+23 -1
View File
@@ -645,6 +645,28 @@ policy** (webhook/service-client only); the member self-view is a validated
action. No new permission code. Phases 2-3 (transactional notifications,
broader self-serve) build on this.
### Transactional notifications (migration 041; product spine Phase 2)
Outbox + dispatcher, not inline send. The Stripe webhook only writes a
`notifications` row (`invoice.paid` → `dues_renewed`, `invoice.payment_failed`
→ `dues_payment_failed`, lapse-to-`late` → `dues_lapsed`); it never calls the
mail provider, so the money path stays fast and retry-safe. The
`(space_id, dedupe_key)` unique index makes a Stripe event replay a no-op
(`ignoreDuplicates`). Pure render + dedupe logic in
`lib/notifications-logic.ts` is unit-tested (HTML-escaped, brand-neutral
copy). `lib/email/send.ts` is a one-function transport seam over Resend's
HTTP API (no SDK; `fetch`) returning a `retryable` flag; a self-hosted
deploy can swap it for SMTP without touching callers, and an unset
`RESEND_API_KEY` is a clean non-retryable no-op. The dispatcher
`POST /api/cron/notifications` (constant-time `CRON_SECRET` bearer, no
session — `proxy.ts` whitelists `/api/cron`) drains ≤20 pending rows per
run, passing each row id as Resend's `Idempotency-Key` so an overlapping
run cannot double-send; transient failures stay `pending` until the attempt
budget is spent, permanent ones go `failed`. `notifications` SELECT =
admin/board/treasurer, no client write policy; the member self-view
(`getMyNotifications`, surfaced on `/me`) is a validated action. The
droplet's crontab POSTs once a minute. No new permission code.
---
## 13. Known Limitations
@@ -653,6 +675,6 @@ broader self-serve) build on this.
2. **Payment integrations** - Manual import/CSV reconciliation is the primary path. A PayPal sync endpoint exists (`app/api/paypal/sync/route.ts`); Stripe/other live APIs are not integrated. See `docs/` and the Payments module section.
3. **Social auth** - GitHub/Google sign-in is wired, gated by the `NEXT_PUBLIC_OAUTH_GITHUB`/`NEXT_PUBLIC_OAUTH_GOOGLE` env flags.
4. **Webhooks** - The HMAC signing contract and secret rotation exist; per-event delivery is not implemented (see `docs/WEBHOOKS.md`).
5. **Email notifications** - Not implemented.
5. **Email notifications** - Dues-lifecycle transactional email is implemented (migration 041: notifications outbox + Resend transport + dispatcher cron). Other domains (bookings, forms) are not wired yet but reuse the same outbox.
6. **Search** - Client-side filtering only, no server-side full-text search.
7. **Single space per user** - The auth resolver assumes one active membership per user; a user in 2+ spaces is not supported (no space switcher). Fails closed.
+1
View File
@@ -721,6 +721,7 @@ migration is mirrored as a section in it. Tables/columns added:
| 038 | `space_visits` (presence/attendance: check-in/out, `is_host`, in/out notes; partial UNIQUE `(space_id,member_id) WHERE checked_out_at IS NULL` = one open visit). RLS additive/default-deny: SELECT = any space member (presence is social), NO client write policy (validated self-resolved service-client actions; immutable history). No new permission code; org-wide attendance view is all-members by product decision. Also adds `spaces.host_requires_card` (bool, default true) gating host check-in, enforced in the app |
| 039 | DATA backfill only (no structural change): links existing `form_submissions.member_id` (where NULL) to the earliest-joined matching `space_members` by `(space_id, lower(email)=lower(submitter_email))`. Idempotent (NULL-only). Not mirrored in schema.sql (fresh DB has no submissions); ongoing linking is app-side (submitForm + linkSubmissionsByEmail) |
| 040 | Stripe recurring dues P1. `payment_platform` += `'stripe'` (ALTER TYPE ADD VALUE; mirrored in the CREATE TYPE list). `member_billing` (member↔Stripe customer/subscription/status/period; SELECT = admin/board/treasurer, NO client write policy — webhook/service-client only). `stripe_webhook_events` (idempotency ledger, PK = Stripe event id; service-client only, no policy). Per-space OWN keys: secrets in the vault, config in integrations.config. No new permission code |
| 041 | Transactional notifications P2. `notifications` outbox (space_id, member_id, type, recipient, subject, body_html/text, status pending/sent/failed, attempts, dedupe_key). `UNIQUE (space_id, dedupe_key)` makes the Stripe-webhook enqueue idempotent (event replay -> no-op). SELECT = admin/board/treasurer, NO client write policy (webhook enqueue + dispatcher cron, service-client only); member self-view via a validated action. Partial index on `(created_at) WHERE status='pending'` for the dispatcher drain. No new permission code |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
+13
View File
@@ -27,6 +27,9 @@ All variables are listed in [.env.example](../.env.example). At minimum:
Optional:
- `NEXT_PUBLIC_OAUTH_GITHUB` / `NEXT_PUBLIC_OAUTH_GOOGLE` — set to `"true"` only for an OAuth provider you have actually configured in Supabase Auth. The login page hides any provider button that is not enabled, and hides the whole social-sign-in block if neither is set. These are build-time public values, so a change requires a redeploy to take effect.
- `RESEND_API_KEY` — Resend HTTP API key for transactional email. Unset means the notification outbox still fills but nothing is sent (dispatcher records each row as failed with "transport not configured").
- `EMAIL_FROM` — from address for all platform email, e.g. `HeatSync Labs <[email protected]>`. The domain must be verified in Resend (SPF + DKIM) for production delivery.
- `CRON_SECRET` — shared secret guarding the notification dispatcher. Generate with `openssl rand -hex 32`. Unset means the dispatcher returns 503 and never sends.
## How a deploy runs
@@ -51,6 +54,16 @@ See [LOCAL_DEV.md](./LOCAL_DEV.md) for the fresh-clone-to-running sequence using
For initial server provisioning (Docker, the Supabase stack, Caddy, firewall, SSH hardening, deploy-key registration, the systemd unit, backup cron), follow [DEPLOY_DO_SELFHOSTED.md](./DEPLOY_DO_SELFHOSTED.md) end to end.
### Notification dispatcher cron
Transactional email is an outbox drained by `POST /api/cron/notifications`. Add a once-a-minute root crontab entry on the Droplet (after `RESEND_API_KEY`, `EMAIL_FROM`, `CRON_SECRET` are set in the app `.env`):
```
* * * * * curl -fsS -m 30 -X POST http://127.0.0.1:3000/api/cron/notifications -H "Authorization: Bearer $CRON_SECRET" >/dev/null 2>&1
```
It hits the app locally (bypassing Caddy), self-throttles under Resend's rate limit, and is idempotent, so a missed or overlapping minute is harmless. Migration `041_notifications.sql` is applied automatically by `deploy.sh` like every other `scripts/0*.sql`; no manual database step.
## Operational checklist
- Monitor `/var/log/hackerspace-deploy.log` for deploy outcomes.
+22
View File
@@ -4,6 +4,28 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-17 (pass 45) — Product spine Phase 2: transactional notifications (LOCAL, awaiting one reviewed deploy)
Branch `main`. Built Phase 2 dues-lifecycle email end to end, gated + committed per sub-phase. NOT deployed (ASK pending). Suite 498, build clean.
### What shipped (commits ddb790a, 5295dc2, 2c88278, 75097a6, + this docs/UI)
- **P2a** migration 041 + schema.sql section 26: `notifications` outbox. Service-only writer, SELECT = admin/board/treasurer, member self-view via validated action. `(space_id, dedupe_key)` unique = idempotent enqueue. Pure `lib/notifications-logic.ts` (render + dedupe + terminal rule), 13 tests.
- **P2b** `lib/email/send.ts`: Resend HTTP transport seam (no SDK, `fetch`), `retryable` flag, unset-key = clean no-op. 6 tests (fetch mocked). `RESEND_API_KEY`/`EMAIL_FROM` in `.env.example`.
- **P2c** Stripe webhook enqueues: `invoice.paid`→renewed, `invoice.payment_failed`→failed, lapse→lapsed. Outbox only, never sends inline (keeps the just-hardened money path fast/retry-safe).
- **P2d** `POST /api/cron/notifications`: constant-time `CRON_SECRET`, drains ≤20/min, row id as Resend Idempotency-Key (overlap-safe). `proxy.ts` whitelists `/api/cron` (same redirect trap as the Stripe webhook). `CRON_SECRET` in `.env.example`.
- **P2e** `getMyNotifications` + read-only `/me` section. Docs updated same change: DATABASE_SCHEMA, DB_SCHEMA_MAP, API_REFERENCE, ARCHITECTURE, DEPLOYMENT (env + crontab line).
### Decision / context
- Transport = Resend (owner indicated an existing Resend token; none was wired in this repo). Behind a one-function seam so SMTP can replace it later without touching callers. Generic-platform constraint preserved.
- Scope locked to dues lifecycle; bookings/forms reuse the same outbox in a later phase.
### Open / needs owner
- **Not deployed.** Awaiting one reviewed deploy (ASK). Also still local: pass-44 docs commit dc76a86.
- After deploy, prod needs: `RESEND_API_KEY`, `EMAIL_FROM` (verified domain, SPF+DKIM), `CRON_SECRET` set on the Droplet, plus the once-a-minute crontab line (see DEPLOYMENT.md). Without them the outbox fills but nothing sends.
- Validation (real test): Stripe test-mode dues run → confirm `dues_renewed` enqueued once, dispatcher sends, `/me` shows it; force a card failure → `dues_payment_failed`; let it lapse → `dues_lapsed`.
---
## 2026-05-17 (pass 44) — Stripe Phase 1 DEPLOYED; post-deploy P0 (webhook routing) caught + fixed + redeployed
Branch `main`. Phase 1 shipped (run 25988762909, HEAD 8f74307, success). Post-deploy smoke caught a P0: an unauthenticated POST to `/api/stripe/webhook/<space>` returned **307**, not 400.