Add notification preferences and alternate dues payment options

Notification preferences (migration 048):
- Per-member opt-out of muteable categories (bookings, classes, forms,
  admin alerts); billing is always-on. Opt-out model, default on.
- Dispatcher reads prefs and marks muted outbox rows 'skipped' instead
  of sending; pure isMuted logic, fail-open on a prefs lookup error.
- notification_preferences table (default-deny RLS); /me toggles and
  self-view via validated service-client actions.

Alternate dues payment methods (migration 049):
- Admin sets external pay-here URLs per platform (PayPal/Zeffy/Venmo)
  in /settings; members see active ones on /me and pay off-platform;
  the treasurer reconciles manually (platform pre-tagged). https-only.
- dues_payment_methods table (SELECT space member, write admin/board).

/me visual pass:
- Restore the chip/preset profile editor (skills/interests/willing-to/
  affiliations); promote ChipInput and presets to shared modules.
- Gate the Stripe pay-dues button on whether the space has Stripe
  configured; show external links or a contact-admin note otherwise.
This commit is contained in:
Moheeb Zara
2026-05-19 20:26:32 -07:00
parent addedfdb0f
commit ab8c22ae1d
29 changed files with 1281 additions and 124 deletions
+60 -1
View File
@@ -685,6 +685,30 @@ zero-decimal currencies (JPY/KRW/VND/etc. are NOT rescaled `/100`, via
the real cause is logged server-side. Phases 2-3 (transactional
notifications, broader self-serve) build on this.
### Alternate dues payment methods (migration 049)
Not every space uses Stripe. An admin can configure external pay-here links
per platform (`dues_payment_methods`, one row per `(space_id, payment_platform)`
for PayPal / Zeffy / Venmo) under `/settings`; members see the active ones as
click-out buttons in the `/me` dues card and pay off-platform; a treasurer
reconciles the resulting payment manually through the existing payments flow.
The row carries the `payment_platform` tag precisely so that manual reconcile
is pre-typed. This is link configuration only: there is NO automated payment
record on click, no webhook, no money handled in-app. Pure logic in
`lib/dues-payments-logic.ts` (the url-based platform subset + labels +
`isSafeDuesUrl`, which requires an absolute `https:` URL so an admin-entered
value cannot become an XSS or plaintext-downgrade vector; the Zod schema reuses
it) is unit-tested. RLS: SELECT = any space member (they render the buttons),
INSERT/UPDATE/DELETE = admin/board (`user_has_role_in_space`); the member-read
and admin-write server actions are in `lib/actions/dues-payments.ts`. Links are
rendered with `target="_blank" rel="noopener noreferrer"`.
The `/me` dues card (`components/billing/dues-card.tsx`) gates the Stripe
"Pay dues with card" button on whether the space actually has Stripe configured
(`getMyBilling` now returns a `configured` flag from `isStripeConfigured`), so a
space with no Stripe shows only its external links (or, if neither is set up, a
"contact an admin" note) instead of a dead Checkout button.
### Transactional notifications (migration 041; product spine Phase 2)
Outbox + dispatcher, not inline send. The Stripe webhook only writes a
@@ -771,6 +795,41 @@ Every enqueue is best-effort and wrapped: a notifications-table or
permission-lookup failure can never throw into the calling action, so the
underlying domain mutation always finalizes.
### Member notification preferences (migration 048; product spine Phase 5)
Per-member opt-out of muteable notification categories. The 11 event types map
to five categories in pure logic (`lib/notifications-prefs-logic.ts`,
unit-tested): `billing` (the three dues types), `bookings`, `classes`, `forms`
(submitter receipts), and `admin_alerts` (the `form_submission_admin` fan-out).
`billing` is deliberately NOT muteable: dues renewed / payment-failed / lapsed
are membership-critical (a muted lapse notice would let a member silently lose
access for non-payment), so they always send and never render a toggle. The
other four are muteable, default-on (opt-out model: a member who never touches
the toggles keeps today's behavior). Adding a new event type only needs a line
in `TYPE_CATEGORY` and a renderer; the storage and dispatcher do not change.
The preference is enforced at **dispatch time**, not enqueue time: the domain
actions and the Stripe webhook are unchanged and still enqueue every row. The
dispatcher batch-loads the preferences for the members in the drain set (keyed
by `member_id`, a globally-unique `space_members` PK) and, via the pure
`isMuted(prefs, type)`, marks a muted row `skipped` (a terminal status that
leaves the pending pool) instead of calling the mail provider. No send, no
Resend call, no rate-limit spacing for a muted row. The prefs lookup fails open
(send everything) on error, so a transient blip can never silently drop a
wanted email. Billing and unmapped types are never muted regardless of stored
prefs (defense-in-depth, tested). This governs emails sent and inbox noise (the
real cost and the volume governor for the Phase 4 fan-outs), not table rows.
`notification_preferences` (PK `(space_id, member_id, category)`) has RLS
enabled and **no client policy** (default-deny), the same convention as
`notifications` / `member_billing`: the member self-view
(`getMyNotificationPreferences`) and the toggle write
(`setMyNotificationPreference`, Zod-validated to the muteable categories only,
upsert scoped to the caller's own member row) both go through validated
service-client actions, and the dispatcher reads via the service client. The
toggles render on the `/me` Activity tab; a `skipped` row shows as "Muted" in
the member's own notification history. No new permission code.
### Member self-serve portal (`/me`; product spine Phase 3)
`/me` is a 3-tab portal (Profile / Membership / Activity). The server page does all data fetching and passes plain data to a single client portal component (`me-portal-client.tsx`) that owns tab state and rendering; the read-only sections were moved verbatim from the prior flat page (no behavior change in the shell). Profile editing and inline cancels reuse existing server actions (`updateMyProfile`, `discloseAffiliations`, `cancelMySignup`, `cancelReservation`) — no new mutation surface, server-side ownership/space scoping unchanged. `getMyPayments` follows the established service-client self-view convention (treasurer-scoped RLS, strictly scoped to the caller). Self-service email change goes through Supabase Auth (`updateUser` + `/auth/confirm` `verifyOtp`); the denormalized `space_members.email` is synced only post-verification. The email-change flow depends on Supabase project config (template + redirect allowlist; see DEPLOYMENT) and is inert until that is set. No new permission code, no schema change.
@@ -783,6 +842,6 @@ underlying domain mutation always finalizes.
2. **Payment integrations** - Stripe recurring dues IS integrated (product spine Phase 1: per-space keys, hosted Checkout/Portal, the per-space signed webhook). A PayPal sync endpoint exists (`app/api/paypal/sync/route.ts`). Manual import/CSV reconciliation remains the path for ad-hoc/other-platform payments; Zeffy/Venmo live APIs are not integrated.
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** - Dues-lifecycle (Phase 2; migration 041) plus booking, class signup, and form-submission events (Phase 4; migration 047) all reuse the same outbox + dispatcher. Member notification preferences (per-type opt-in/out) and an in-app notification center are the next planned passes; today every event is enqueued unconditionally.
5. **Email notifications** - Dues-lifecycle (Phase 2; migration 041) plus booking, class signup, and form-submission events (Phase 4; migration 047) all reuse the same outbox + dispatcher. Member notification preferences (Phase 5; migration 048) let a member opt out of the muteable categories (bookings, classes, forms, admin alerts); billing is always-on. Enforced at dispatch time (muted rows are marked `skipped`, never sent). An in-app notification center (read/unread state) is the remaining planned pass.
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.
+2
View File
@@ -728,6 +728,8 @@ migration is mirrored as a section in it. Tables/columns added:
| 045 | `class_signup_tx(p_session_id,p_space_id,p_member_id)` + `class_cancel_tx(...)` SECURITY DEFINER, `search_path=public`. Each takes `pg_advisory_xact_lock(hashtext(session_id))` then does the capacity-decision+dup-check+insert (signup) or cancel+conditional waitlist-promote (cancel) atomically. Closes the concurrent over-enroll / double-promote races (dynamic capacity can't be a constraint). Mirrors `computeSignupStatus`/`pickPromotion`; the SQL is the runtime authority. No new table/column/policy |
| 046 | `user_has_role_in_space` and `user_has_permission` extended with `status IN ('current','late')` (mirrors `lib/permissions` `PRIVILEGE_STATUSES`). RLS-layer defense-in-depth for D2 (the require_approval / unverified-admin escalation): a non-privilege-eligible member who somehow bypassed the app guards still cannot exercise a role or permission. `get_user_space_ids` and `user_effective_roles` are deliberately NOT gated so unverified members keep their own /me + onboarding reads (the legitimate require_approval flow). No new table/column/policy |
| 047 | `members_with_permission(sid, perm)` SECURITY DEFINER, `search_path=public`. Inverted, set-returning form of `user_has_permission`: returns `member_id` for every member of `sid` who holds `perm` (same current/late status gate as 046, same admin shortcut, same `space_role_permissions` + `user_effective_roles` fallback). Lets the Phase 4 notification fan-outs (e.g. `form_submission_admin`) find permission holders in one query instead of N round-trips. EXECUTE is locked: `REVOKE EXECUTE FROM PUBLIC`, `GRANT EXECUTE TO service_role` so the set-returning shape cannot be used by a PostgREST client to enumerate "who has permission X in space Y" across spaces (the rationale for not locking `user_has_permission` is that RLS policies depend on anon/authenticated being able to call it). Additive; existing `user_has_permission` callers unchanged. No new table/column/policy |
| 048 | `notification_preferences` (Product spine Phase 5): per-member opt-out of muteable categories. Columns `space_id, member_id, category, enabled`, PK `(space_id, member_id, category)`. Opt-out model: absence of a row = enabled. Only the four muteable categories (`bookings, classes, forms, admin_alerts`) are ever stored; `billing` (dues renewed/failed/lapsed) is membership-critical and never muteable. RLS enabled, NO client policy (default-deny): the dispatcher reads via the service client and marks a muted outbox row `skipped`; the `/me` self-view + toggle write both go through validated service-client actions, same convention as `notifications` / `member_billing`. `touch_updated_at` trigger. No new permission code |
| 049 | `dues_payment_methods`: admin-configured external dues payment links. Columns `id, space_id, platform (payment_platform), url, instructions, is_active, sort_order`, `UNIQUE (space_id, platform)`. One external pay-here URL per platform (PayPal/Zeffy/Venmo); members click out to pay dues off-platform and a treasurer reconciles manually via the existing payments flow (the platform tag pre-types that reconcile). Link configuration only, no automated payment record. RLS: SELECT = any space member (they render the buttons); INSERT/UPDATE/DELETE = admin/board (`user_has_role_in_space`). `url` is validated absolute-https at the server-action boundary. `touch_updated_at` trigger. No new permission code |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
+56
View File
@@ -4,6 +4,62 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-19 (pass 58): Dues payment options + /me visual pass (LOCAL, NOT deployed)
Branch `main`. Same session as pass-57; addressed three follow-up requests the user raised after the notification-prefs work. Suite 593 (was 585; +8 dues-payments tests), build clean. NOT deployed. Stacks on top of the uncommitted pass-57 changes.
### Design forks (locked via AskUserQuestion)
- /me polish scope: **full visual pass** (chips/presets restored AND broader tightening), staged for user review.
- Alternate payment model: **platform-typed** (tied to the `payment_platform` enum, one URL per platform, pre-tagged for manual reconcile), not free-form.
### Shipped (local, uncommitted)
- **Alternate dues payment methods (migration 049).** New `dues_payment_methods` table (one row per `(space_id, platform)`, PayPal/Zeffy/Venmo), RLS SELECT = space member / write = admin/board. Pure logic `lib/dues-payments-logic.ts` (`DUES_LINK_PLATFORMS`, labels, `isSafeDuesUrl` = absolute-https only; the Zod schema reuses it). Actions `lib/actions/dues-payments.ts` (member `listActiveDuesPaymentMethods`; admin `listDuesPaymentMethods` / `upsertDuesPaymentMethod` / `deleteDuesPaymentMethod`). Admin UI `components/settings/dues-payment-methods-panel.tsx` appended to `/settings`. Member UI = the reworked dues card. Link configuration only: no payment record on click, treasurer reconciles manually via the existing payments flow (platform pre-typed). schema.sql + types + DATABASE_SCHEMA + DB_SCHEMA_MAP updated same-change.
- **Stripe dues UI gated on configuration.** `getMyBilling` now returns a `configured` flag (`isStripeConfigured`); the dues card shows the Stripe "Pay dues with card" button only when the space has Stripe set up. With no Stripe + no external links it shows a "contact an admin" note instead of a dead Checkout button.
- **/me full visual pass.** The Phase-3 `/me` profile editor had regressed to plain comma-separated text inputs; restored the chip/preset editor. Promoted `ChipInput` to a shared `components/chip-input.tsx` and `WILLING_TO_SUGGESTIONS` to `lib/profile-presets.ts`; both `/me` and `/profile` now use them (deduped; deleted `app/(app)/profile/chip-input.tsx`). `/me` profile-form: skills/interests/willing-to/affiliations are now chips (willing-to with the recruitment-role suggestions); cards use `rounded-lg`, consistent field labels. Dues card restyled.
### Audit (one pass)
- Made `isSafeDuesUrl` load-bearing (the Zod url field now refines on it, removing the duplicate inline https check). Verified ChipInput limits match the profile Zod schema (skills/interests 40×60, willing_to 20×60, affiliations 50×200). Confirmed `getMyBilling`'s only real caller is `/me` (shape change from `null` to always-object is safe). External links render `target=_blank rel=noopener noreferrer`; url validated absolute-https so no javascript:/data: scheme.
### NOT done / open
- **NOT deployed, NOT committed.** Migrations 048 (notification_preferences, pass-57) + 049 (dues_payment_methods) both apply on deploy. No new env var; DEPLOYMENT unchanged.
- **Browser click-through not done.** Verified type-check + build + 593 unit tests only; `/me` and `/settings` are auth-gated and need a live Supabase session + a space (and Stripe/links data) to render. The /me visual pass is explicitly staged for the user to review in a browser. Flagging per the working agreement (do not claim UI success without rendering it).
- **No integration test** for dues_payment_methods RLS or the dispatcher skip (pass-57). Pure logic for both is unit-covered. Recommended follow-up (harness exists).
- **Deploy bundling decision pending:** pass-57 (notification prefs) + pass-58 can ship together (one deploy, migrations 048+049) or separately. Awaiting user go.
---
## 2026-05-19 (pass 57): Product spine Phase 5: member notification preferences (LOCAL, NOT deployed)
Branch `main`. Took backlog item 2: per-member opt-out of muteable notification categories, doubling as the volume governor for the Phase 4 fan-outs. Design-first via AskUserQuestion. Suite 585 (was 572; +13 prefs tests), build clean. NOT deployed.
### Design forks (locked via AskUserQuestion)
- Granularity: **per-category** (4 toggles: bookings, classes, forms, admin_alerts), not per-type or global.
- Critical email: **billing always-on** (dues renewed/failed/lapsed never muteable; a muted lapse notice would let a member silently lose access).
- Enforcement point: **dispatch-time skip** (rows still enqueue; dispatcher marks muted rows `skipped`), not enqueue-time suppression. Keeps pref logic in one place; the 5 enqueue call sites + the money-path webhook are untouched.
- Asserted (not asked): opt-out / default-on (opt-in would silently break transactional delivery).
### Shipped (local, uncommitted)
- **Pure logic** `lib/notifications-prefs-logic.ts`: 5 categories, `TYPE_CATEGORY` map (all 11 types), `categoryForType`, `isMuted` (billing + unmapped types never mute), `CATEGORY_META` for the UI. 13 unit tests.
- **Migration 048** + schema.sql: `notification_preferences` (PK `(space_id, member_id, category)`), RLS on, NO client policy (default-deny, same convention as notifications/member_billing). types/database.ts + DATABASE_SCHEMA.md + DB_SCHEMA_MAP.md updated same-change.
- **Dispatcher** (`app/api/cron/notifications/route.ts`): selects member_id + type, batch-loads prefs for the drain set (keyed by member_id, a globally-unique PK), marks muted rows `skipped` (terminal, leaves pending pool, no send/Resend/spacing). Prefs lookup fails open (send everything) on error so a blip can't drop a wanted email. Response gains a `skipped` count.
- **Actions** (`lib/actions/notifications.ts`): `getMyNotificationPreferences` (defaults all muteable on, overlays stored rows) + `setMyNotificationPreference` (Zod enum excludes billing, upsert scoped to caller's own member row). Both service-client, requireMember-gated.
- **UI**: `/me` Activity tab gains an "Email preferences" section (4 Switch toggles, optimistic + revert-on-error); the notification history badge now renders `skipped` as "Muted".
### Audit (one pass)
No deploy-blocking bug found. Verified: write/read both scoped to the authenticated member (no cross-member write — member_id comes from session, not input); billing not settable (Zod) and never muted (logic + test); `skipped` is terminal so muted rows clear the pending pool in one run and never re-scan; overlapping dispatcher runs can't double-act (the existing `.eq('status','pending')` guard); member_id-keyed prefs map is unambiguous (space_members PK is global). Made one improvement during audit: explicit fail-open + log on the prefs-query error.
### Open / not done
- **NOT deployed.** Awaiting user go (push to main = prod; migration 048 applies on deploy). Same-as-Phase-4: inert until Resend + CRON_SECRET + crontab are provisioned, but the skip logic is exercised the moment a member toggles + the dispatcher runs.
- **No integration test yet** for the dispatcher skip path. Pure `isMuted` is unit-covered; the dispatcher wiring (prefs query + skip write) is not. Flagged as a recommended follow-up (the harness exists; pattern is stripe-webhook.test.ts). Deliberate, not an omission.
- **API_REFERENCE.md** still predates the whole notification subsystem (Phases 2-5 self-view actions absent). Pre-existing drift, not introduced here; documented in ARCHITECTURE prose subsections instead (matches Phases 2-4).
### NEW user requests this session (queued, address next, NOT started)
1. **/me UI degradation.** `willing_to` (likely also skills/interests) renders as a raw comma-separated list instead of the preset chip selector it used to have. Restore the preset editor. Own commit (separate from the prefs toggles even though same file).
2. **Gate the Stripe dues UI on configuration.** If a space hasn't set up Stripe, the pay-dues-via-Stripe UI on `/me` should not show (dead path today).
3. **Admin-configurable alternate dues payment links.** Admin sets payment URLs per method (PayPal/Zeffy/Venmo/etc); members see them as dues-payment options, click through to the external page, admin reconciles manually later. Money-adjacent new subsystem: design-first via AskUserQuestion before building. Separate deploy.
---
## 2026-05-19 (pass 56): Product spine Phase 4: notification breadth (DEPLOYED)
Branch `main`. Took item 3 from the pass-55 backlog: booking, class signup, and form-submission notifications, reusing the Phase 2 outbox + dispatcher unchanged. Built per sub-phase, gated each, small commit per sub-phase, single deploy at the end of the set. Suite 565 (was 532; +33 new tests), build clean. NOT deployed.