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,