From ab8c22ae1da65339c4cd70e3f2ebfc1cfc328105 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Tue, 19 May 2026 20:26:32 -0700 Subject: [PATCH] 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. --- DB_SCHEMA_MAP.md | 4 + __tests__/dues-payments-logic.test.ts | 51 ++++++ __tests__/notifications-prefs-logic.test.ts | 87 +++++++++ app/(app)/me/me-portal-client.tsx | 28 ++- app/(app)/me/notification-prefs.tsx | 52 ++++++ app/(app)/me/page.tsx | 16 +- app/(app)/me/profile-form.tsx | 168 ++++++++++-------- app/(app)/profile/affiliations-form.tsx | 2 +- app/(app)/profile/profile-form.tsx | 13 +- app/(app)/settings/page.tsx | 2 + app/api/cron/notifications/route.ts | 48 ++++- components/billing/dues-card.tsx | 97 ++++++++-- .../profile => components}/chip-input.tsx | 0 .../settings/dues-payment-methods-panel.tsx | 149 ++++++++++++++++ docs/ARCHITECTURE.md | 61 ++++++- docs/DATABASE_SCHEMA.md | 2 + docs/HANDOFF.md | 56 ++++++ lib/actions/dues-payments.ts | 124 +++++++++++++ lib/actions/index.ts | 3 + lib/actions/notifications.ts | 67 +++++++ lib/actions/stripe.ts | 21 ++- lib/dues-payments-logic.ts | 36 ++++ lib/notifications-prefs-logic.ts | 93 ++++++++++ lib/profile-presets.ts | 12 ++ lib/validations.ts | 30 ++++ scripts/048_notification_preferences.sql | 41 +++++ scripts/049_dues_payment_methods.sql | 59 ++++++ scripts/schema.sql | 71 ++++++++ types/database.ts | 12 ++ 29 files changed, 1281 insertions(+), 124 deletions(-) create mode 100644 __tests__/dues-payments-logic.test.ts create mode 100644 __tests__/notifications-prefs-logic.test.ts create mode 100644 app/(app)/me/notification-prefs.tsx rename {app/(app)/profile => components}/chip-input.tsx (100%) create mode 100644 components/settings/dues-payment-methods-panel.tsx create mode 100644 lib/actions/dues-payments.ts create mode 100644 lib/dues-payments-logic.ts create mode 100644 lib/notifications-prefs-logic.ts create mode 100644 lib/profile-presets.ts create mode 100644 scripts/048_notification_preferences.sql create mode 100644 scripts/049_dues_payment_methods.sql diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index 8990528..e94c403 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -413,6 +413,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 045 | `class_signup_tx` / `class_cancel_tx` SECURITY DEFINER functions: capacity-decision+insert and cancel+waitlist-promote run under a per-session `pg_advisory_xact_lock` (concurrent signups can't over-enroll; concurrent cancels can't double-promote). Mirrors the pure computeSignupStatus/pickPromotion rule; RPC is runtime authority. No new table/column | | 046 | `user_has_role_in_space` + `user_has_permission` gated on `status IN ('current','late')` (RLS-layer defense-in-depth for D2): unverified/inactive can no longer exercise a role/permission via direct PostgREST. `get_user_space_ids` + `user_effective_roles` intentionally NOT gated (would break unverified self-reads / onboarding). No new table/column | | 047 | `members_with_permission(sid, perm)` SECURITY DEFINER, set-returning inverse of `user_has_permission` (same current/late gate, same admin shortcut + `space_role_permissions`/`user_effective_roles` fallback). Lets Phase 4 fan-outs (e.g. `form_submission_admin`) resolve permission holders in one query. No new table/column/policy | +| 048 | `notification_preferences` (Phase 5): per-member opt-out of muteable categories (`bookings, classes, forms, admin_alerts`); `billing` never muteable/stored. Cols `space_id, member_id, category, enabled`; PK `(space_id, member_id, category)`. Opt-out: no row = enabled. RLS on, NO client policy (default-deny); dispatcher reads via service client and marks muted rows `skipped`; `/me` view + toggle via validated service-client actions. `touch_updated_at` trigger | +| 049 | `dues_payment_methods`: admin-configured external dues links. Cols `id, space_id, platform (payment_platform), url, instructions, is_active, sort_order`; `UNIQUE (space_id, platform)`. One pay-here URL per platform (PayPal/Zeffy/Venmo); members click out, treasurer reconciles manually (platform pre-typed). RLS: SELECT any space member, write admin/board. `url` validated https-only at the action boundary. `touch_updated_at` trigger | --- @@ -450,6 +452,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | `member_billing` | space_id, member_id (UNIQUE per space), stripe_customer_id, stripe_subscription_id, subscription_status, current_period_end | Stripe dues link. SELECT = admin/board/treasurer; NO client write policy (webhook/service-client only). Member self-view via a validated action | | `stripe_webhook_events` | event_id PK, space_id, type, received_at | Webhook idempotency ledger (dedupe on Stripe's stable event id). Service-client only; no RLS policy | | `notifications` | space_id, member_id, type, channel, recipient, subject, body_html, body_text, status, attempts, last_error, dedupe_key (UNIQUE per space), sent_at | Transactional email outbox. Webhook enqueues (idempotent via the unique key); dispatcher cron sends via Resend with row id as Idempotency-Key. SELECT = admin/board/treasurer; NO client write policy (service-client only). Member self-view via a validated action | +| `notification_preferences` | space_id, member_id, category, enabled; PK (space_id, member_id, category) | Per-member opt-out of muteable notification categories (bookings, classes, forms, admin_alerts); billing never stored. Opt-out: no row = enabled. NO client policy (default-deny); dispatcher reads via service client and marks muted outbox rows `skipped`; `/me` view + toggle via validated service-client actions | +| `dues_payment_methods` | id, space_id, platform, url, instructions, is_active, sort_order; UNIQUE (space_id, platform) | Admin-configured external dues payment links (PayPal/Zeffy/Venmo). Members click out to pay off-platform; treasurer reconciles manually (platform pre-typed). SELECT = any space member; write = admin/board. url validated https-only | Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`; `classes.required_form_id`; `spaces.host_requires_card`. New enum `comment_entity_type`. diff --git a/__tests__/dues-payments-logic.test.ts b/__tests__/dues-payments-logic.test.ts new file mode 100644 index 0000000..7606904 --- /dev/null +++ b/__tests__/dues-payments-logic.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest' +import { + DUES_LINK_PLATFORMS, + DUES_PLATFORM_LABEL, + isDuesLinkPlatform, + isSafeDuesUrl, +} from '@/lib/dues-payments-logic' + +describe('DUES_LINK_PLATFORMS', () => { + it('is the url-based subset, excluding cash and stripe', () => { + expect(DUES_LINK_PLATFORMS).toEqual(['paypal', 'zeffy', 'venmo']) + expect((DUES_LINK_PLATFORMS as readonly string[]).includes('cash')).toBe(false) + expect((DUES_LINK_PLATFORMS as readonly string[]).includes('stripe')).toBe(false) + }) + + it('has a label for every platform', () => { + for (const p of DUES_LINK_PLATFORMS) expect(DUES_PLATFORM_LABEL[p]).toBeTruthy() + }) +}) + +describe('isDuesLinkPlatform', () => { + it('accepts the link platforms', () => { + expect(isDuesLinkPlatform('paypal')).toBe(true) + expect(isDuesLinkPlatform('venmo')).toBe(true) + }) + it('rejects cash, stripe, and junk', () => { + expect(isDuesLinkPlatform('cash')).toBe(false) + expect(isDuesLinkPlatform('stripe')).toBe(false) + expect(isDuesLinkPlatform('bogus')).toBe(false) + }) +}) + +describe('isSafeDuesUrl', () => { + it('accepts absolute https URLs', () => { + expect(isSafeDuesUrl('https://paypal.me/myspace')).toBe(true) + expect(isSafeDuesUrl('https://www.zeffy.com/en-US/donation-form/abc')).toBe(true) + }) + it('rejects http (plaintext downgrade)', () => { + expect(isSafeDuesUrl('http://paypal.me/myspace')).toBe(false) + }) + it('rejects javascript:, data:, and other schemes (XSS)', () => { + expect(isSafeDuesUrl('javascript:alert(1)')).toBe(false) + expect(isSafeDuesUrl('data:text/html,