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:
+60
-1
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user