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
+4
View File
@@ -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 | | 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 | | 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 | | 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 | | `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 | | `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 | | `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`. 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`.
+51
View File
@@ -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,<script>')).toBe(false)
expect(isSafeDuesUrl('mailto:[email protected]')).toBe(false)
})
it('rejects relative or unparseable input', () => {
expect(isSafeDuesUrl('/paypal')).toBe(false)
expect(isSafeDuesUrl('paypal.me/x')).toBe(false)
expect(isSafeDuesUrl('')).toBe(false)
})
})
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest'
import {
MUTEABLE_CATEGORIES,
TYPE_CATEGORY,
CATEGORY_META,
categoryForType,
isMuteableCategory,
isMuted,
} from '@/lib/notifications-prefs-logic'
describe('categoryForType', () => {
it('maps every dues type to billing', () => {
expect(categoryForType('dues_renewed')).toBe('billing')
expect(categoryForType('dues_payment_failed')).toBe('billing')
expect(categoryForType('dues_lapsed')).toBe('billing')
})
it('maps booking, class, and form types to their categories', () => {
expect(categoryForType('booking_confirmed')).toBe('bookings')
expect(categoryForType('booking_cancelled')).toBe('bookings')
expect(categoryForType('class_signup_registered')).toBe('classes')
expect(categoryForType('class_signup_waitlisted')).toBe('classes')
expect(categoryForType('class_signup_promoted')).toBe('classes')
expect(categoryForType('class_session_cancelled')).toBe('classes')
expect(categoryForType('form_submission_received')).toBe('forms')
expect(categoryForType('form_submission_admin')).toBe('admin_alerts')
})
it('returns null for an unmapped type', () => {
expect(categoryForType('something_new')).toBeNull()
})
})
describe('isMuteableCategory', () => {
it('billing is never muteable', () => {
expect(isMuteableCategory('billing')).toBe(false)
})
it('bookings, classes, forms, admin_alerts are muteable', () => {
for (const c of MUTEABLE_CATEGORIES) expect(isMuteableCategory(c)).toBe(true)
})
})
describe('isMuted', () => {
it('absent pref means enabled (opt-out default): not muted', () => {
expect(isMuted({}, 'booking_confirmed')).toBe(false)
})
it('explicitly disabled muteable category: muted', () => {
expect(isMuted({ bookings: false }, 'booking_confirmed')).toBe(true)
expect(isMuted({ classes: false }, 'class_signup_registered')).toBe(true)
expect(isMuted({ forms: false }, 'form_submission_received')).toBe(true)
expect(isMuted({ admin_alerts: false }, 'form_submission_admin')).toBe(true)
})
it('explicitly enabled: not muted', () => {
expect(isMuted({ bookings: true }, 'booking_confirmed')).toBe(false)
})
it('billing is never muted even if a stray pref tries to disable it', () => {
// billing is never written, but defend the dispatcher path regardless.
expect(isMuted({ billing: false } as never, 'dues_lapsed')).toBe(false)
expect(isMuted({ billing: false } as never, 'dues_payment_failed')).toBe(false)
expect(isMuted({}, 'dues_renewed')).toBe(false)
})
it('unmapped type is never muted (fail-open)', () => {
expect(isMuted({ bookings: false }, 'brand_new_event')).toBe(false)
})
it('a pref for one category does not affect another', () => {
expect(isMuted({ classes: false }, 'booking_confirmed')).toBe(false)
})
})
describe('CATEGORY_META', () => {
it('lists exactly the muteable categories, no billing', () => {
const cats = CATEGORY_META.map(m => m.category)
expect(cats).toEqual([...MUTEABLE_CATEGORIES])
expect(cats).not.toContain('billing')
})
it('every emitted type maps to a known category', () => {
for (const cat of Object.values(TYPE_CATEGORY)) {
expect(['billing', ...MUTEABLE_CATEGORIES]).toContain(cat)
}
})
})
+24 -4
View File
@@ -10,9 +10,11 @@ import {
import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic' import { SIGNUP_STATUS_LABEL, SESSION_STATUS_LABEL } from '@/lib/classes-logic'
import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic' import { RESERVATION_STATUS_LABEL } from '@/lib/equipment-logic'
import { presenceStatus } from '@/lib/presence-logic' import { presenceStatus } from '@/lib/presence-logic'
import { DuesCard } from '@/components/billing/dues-card' import { DuesCard, type DuesMethod } from '@/components/billing/dues-card'
import { ProfileForm, type ProfileInitial } from './profile-form' import { ProfileForm, type ProfileInitial } from './profile-form'
import { CancelAction } from './cancel-action' import { CancelAction } from './cancel-action'
import { NotificationPrefs } from './notification-prefs'
import type { PrefMap } from '@/lib/notifications-prefs-logic'
import { cancelMySignup, cancelReservation } from '@/lib/actions' import { cancelMySignup, cancelReservation } from '@/lib/actions'
const STATUS_VARIANT: Record<CertStatus, 'default' | 'outline'> = { const STATUS_VARIANT: Record<CertStatus, 'default' | 'outline'> = {
@@ -54,15 +56,17 @@ export type MyVisit = { id: string; checked_in_at: string; checked_out_at: strin
export type MyNotif = { id: string; type: string; subject: string; status: string; createdAt: string; sentAt: string | null } export type MyNotif = { id: string; type: string; subject: string; status: string; createdAt: string; sentAt: string | null }
export type MyPayment = { id: string; amount: number; currency: string; platform: string; description: string | null; status: string; date: string } export type MyPayment = { id: string; amount: number; currency: string; platform: string; description: string | null; status: string; date: string }
export type HeldPermission = { code: string; label: string } export type HeldPermission = { code: string; label: string }
export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null export type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean; configured: boolean } | null
type Props = { type Props = {
profile: ProfileInitial profile: ProfileInitial
grants: Grant[] grants: Grant[]
held: HeldPermission[] held: HeldPermission[]
billing: Billing billing: Billing
duesMethods: DuesMethod[]
payments: MyPayment[] payments: MyPayment[]
notifs: MyNotif[] notifs: MyNotif[]
notifPrefs: PrefMap
classSignups: ClassSignup[] classSignups: ClassSignup[]
reservations: Reservation[] reservations: Reservation[]
myCards: MyCard[] myCards: MyCard[]
@@ -76,8 +80,10 @@ export function MePortalClient({
grants, grants,
held, held,
billing, billing,
duesMethods,
payments, payments,
notifs, notifs,
notifPrefs,
classSignups, classSignups,
reservations, reservations,
myCards, myCards,
@@ -135,7 +141,7 @@ export function MePortalClient({
<TabsContent value="membership" className="space-y-8"> <TabsContent value="membership" className="space-y-8">
<section> <section>
<h2 className={sectionH}>Dues</h2> <h2 className={sectionH}>Dues</h2>
<DuesCard billing={billing} /> <DuesCard billing={billing} methods={duesMethods} />
</section> </section>
<section> <section>
@@ -210,6 +216,14 @@ export function MePortalClient({
</TabsContent> </TabsContent>
<TabsContent value="activity" className="space-y-8"> <TabsContent value="activity" className="space-y-8">
<section>
<h2 className={sectionH}>Email preferences</h2>
<NotificationPrefs initial={notifPrefs} />
<p className="font-sans text-xs text-muted-foreground mt-3">
Billing emails (dues receipts, payment alerts) are always sent.
</p>
</section>
<section> <section>
<h2 className={sectionH}>Notifications</h2> <h2 className={sectionH}>Notifications</h2>
{notifs.length === 0 ? ( {notifs.length === 0 ? (
@@ -228,7 +242,13 @@ export function MePortalClient({
</p> </p>
</div> </div>
<Badge variant={n.status === 'sent' ? 'default' : 'outline'}> <Badge variant={n.status === 'sent' ? 'default' : 'outline'}>
{n.status === 'sent' ? 'Sent' : n.status === 'failed' ? 'Failed' : 'Queued'} {n.status === 'sent'
? 'Sent'
: n.status === 'failed'
? 'Failed'
: n.status === 'skipped'
? 'Muted'
: 'Queued'}
</Badge> </Badge>
</li> </li>
))} ))}
+52
View File
@@ -0,0 +1,52 @@
'use client'
import { useState, useTransition } from 'react'
import { toast } from 'sonner'
import { Switch } from '@/components/ui/switch'
import {
CATEGORY_META,
type PrefMap,
type NotificationCategory,
} from '@/lib/notifications-prefs-logic'
import { setMyNotificationPreference } from '@/lib/actions'
// Per-category email opt-out toggles. Optimistic: the switch flips immediately
// and reverts if the server action fails. Billing is never shown (always on).
export function NotificationPrefs({ initial }: { initial: PrefMap }) {
const [prefs, setPrefs] = useState<PrefMap>(initial)
const [pending, startTransition] = useTransition()
function toggle(category: NotificationCategory, next: boolean) {
const prev = prefs[category] ?? true
setPrefs(p => ({ ...p, [category]: next }))
startTransition(async () => {
const res = await setMyNotificationPreference({ category, enabled: next })
if ('error' in res) {
setPrefs(p => ({ ...p, [category]: prev }))
toast.error(res.error)
}
})
}
return (
<ul className="divide-y rounded-lg border border-border">
{CATEGORY_META.map(meta => {
const enabled = prefs[meta.category] ?? true
return (
<li key={meta.category} className="p-4 flex items-center justify-between gap-4">
<div className="min-w-0">
<span className="font-sans text-sm text-foreground">{meta.label}</span>
<p className="font-sans text-xs text-muted-foreground mt-0.5">{meta.description}</p>
</div>
<Switch
checked={enabled}
disabled={pending}
onCheckedChange={next => toggle(meta.category, next)}
aria-label={`Email me about ${meta.label}`}
/>
</li>
)
})}
</ul>
)
}
+14 -2
View File
@@ -3,7 +3,9 @@ import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin' import { createAdminClient } from '@/lib/supabase/admin'
import { PageHeader, PageTitle } from '@/components/ui/page-title' import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog' import { PERMISSIONS, PERMISSION_CODES } from '@/lib/permissions-catalog'
import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits, getMyBilling, getMyNotifications, getMyPayments } from '@/lib/actions' import { getMyClassSignups, getMyReservations, getMyCards, getMyVisits, getMyBilling, getMyNotifications, getMyNotificationPreferences, getMyPayments, listActiveDuesPaymentMethods } from '@/lib/actions'
import type { PrefMap } from '@/lib/notifications-prefs-logic'
import type { DuesMethod } from '@/components/billing/dues-card'
import { import {
MePortalClient, MePortalClient,
type Grant, type Grant,
@@ -107,12 +109,20 @@ export default async function MePage() {
const billingRes = await getMyBilling() const billingRes = await getMyBilling()
const billing = ('data' in billingRes ? billingRes.data : null) as const billing = ('data' in billingRes ? billingRes.data : null) as
| { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean; configured: boolean }
| null | null
const duesMethodsRes = await listActiveDuesPaymentMethods()
const duesMethods: DuesMethod[] = 'data' in duesMethodsRes
? duesMethodsRes.data.map(m => ({ platform: m.platform, url: m.url, instructions: m.instructions }))
: []
const notifsRes = await getMyNotifications() const notifsRes = await getMyNotifications()
const notifs: MyNotif[] = 'data' in notifsRes ? (notifsRes.data as MyNotif[]) : [] const notifs: MyNotif[] = 'data' in notifsRes ? (notifsRes.data as MyNotif[]) : []
const notifPrefsRes = await getMyNotificationPreferences()
const notifPrefs: PrefMap = 'data' in notifPrefsRes ? notifPrefsRes.data : {}
const paymentsRes = await getMyPayments() const paymentsRes = await getMyPayments()
const payments: MyPayment[] = 'data' in paymentsRes ? (paymentsRes.data as MyPayment[]) : [] const payments: MyPayment[] = 'data' in paymentsRes ? (paymentsRes.data as MyPayment[]) : []
@@ -127,8 +137,10 @@ export default async function MePage() {
grants={grants} grants={grants}
held={held} held={held}
billing={billing} billing={billing}
duesMethods={duesMethods}
payments={payments} payments={payments}
notifs={notifs} notifs={notifs}
notifPrefs={notifPrefs}
classSignups={classSignups} classSignups={classSignups}
reservations={reservations} reservations={reservations}
myCards={myCards} myCards={myCards}
+90 -78
View File
@@ -7,6 +7,8 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { ChipInput } from '@/components/chip-input'
import { WILLING_TO_SUGGESTIONS } from '@/lib/profile-presets'
import { updateMyProfile, discloseAffiliations, requestEmailChange } from '@/lib/actions' import { updateMyProfile, discloseAffiliations, requestEmailChange } from '@/lib/actions'
export type ProfileInitial = { export type ProfileInitial = {
@@ -21,11 +23,11 @@ export type ProfileInitial = {
affiliations: string[] affiliations: string[]
} }
const toList = (s: string): string[] =>
s.split(',').map(x => x.trim()).filter(Boolean)
const sameList = (a: string[], b: string[]) => const sameList = (a: string[], b: string[]) =>
a.length === b.length && a.every((x, i) => x === b[i]) a.length === b.length && a.every((x, i) => x === b[i])
const fieldLabel = 'font-mono text-[10px] tracking-widest text-muted-foreground uppercase'
export function ProfileForm({ initial }: { initial: ProfileInitial }) { export function ProfileForm({ initial }: { initial: ProfileInitial }) {
const router = useRouter() const router = useRouter()
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
@@ -35,10 +37,10 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
const [handle, setHandle] = useState(initial.handle) const [handle, setHandle] = useState(initial.handle)
const [phone, setPhone] = useState(initial.phone) const [phone, setPhone] = useState(initial.phone)
const [bio, setBio] = useState(initial.bio) const [bio, setBio] = useState(initial.bio)
const [skills, setSkills] = useState(initial.skills.join(', ')) const [skills, setSkills] = useState<string[]>(initial.skills)
const [interests, setInterests] = useState(initial.interests.join(', ')) const [interests, setInterests] = useState<string[]>(initial.interests)
const [willingTo, setWillingTo] = useState(initial.willing_to.join(', ')) const [willingTo, setWillingTo] = useState<string[]>(initial.willing_to)
const [affiliations, setAffiliations] = useState(initial.affiliations.join(', ')) const [affiliations, setAffiliations] = useState<string[]>(initial.affiliations)
async function save() { async function save() {
if (!displayName.trim()) return toast.error('Display name is required.') if (!displayName.trim()) return toast.error('Display name is required.')
@@ -49,9 +51,9 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
handle: handle.trim() || null, handle: handle.trim() || null,
phone: phone.trim() || null, phone: phone.trim() || null,
bio: bio.trim() || null, bio: bio.trim() || null,
skills: toList(skills), skills,
interests: toList(interests), interests,
willing_to: toList(willingTo), willing_to: willingTo,
}) })
if ('error' in res && res.error) { if ('error' in res && res.error) {
setBusy(false) setBusy(false)
@@ -60,9 +62,8 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
// Only re-disclose affiliations when they actually changed, so the // Only re-disclose affiliations when they actually changed, so the
// conflict-of-interest disclosure timestamp is not reset on every save. // conflict-of-interest disclosure timestamp is not reset on every save.
const nextAffiliations = toList(affiliations) if (!sameList(affiliations, initial.affiliations)) {
if (!sameList(nextAffiliations, initial.affiliations)) { const aff = await discloseAffiliations({ affiliations })
const aff = await discloseAffiliations({ affiliations: nextAffiliations })
if ('error' in aff && aff.error) { if ('error' in aff && aff.error) {
setBusy(false) setBusy(false)
return toast.error(aff.error) return toast.error(aff.error)
@@ -88,83 +89,94 @@ export function ProfileForm({ initial }: { initial: ProfileInitial }) {
} }
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="bg-card rounded border border-border p-4 space-y-4"> <div className="bg-card rounded-lg border border-border p-5 space-y-5">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="pf-name">Display name</Label> <Label htmlFor="pf-name">Display name</Label>
<Input id="pf-name" value={displayName} onChange={e => setDisplayName(e.target.value)} maxLength={100} /> <Input id="pf-name" value={displayName} onChange={e => setDisplayName(e.target.value)} maxLength={100} />
</div>
<div className="space-y-1.5">
<Label htmlFor="pf-handle">Handle</Label>
<Input id="pf-handle" value={handle} onChange={e => setHandle(e.target.value)} maxLength={50} placeholder="optional" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pf-phone">Phone</Label>
<Input id="pf-phone" value={phone} onChange={e => setPhone(e.target.value)} maxLength={20} placeholder="optional" />
</div>
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="pf-handle">Handle</Label>
<Input id="pf-handle" value={handle} onChange={e => setHandle(e.target.value)} maxLength={50} placeholder="optional" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pf-phone">Phone</Label>
<Input id="pf-phone" value={phone} onChange={e => setPhone(e.target.value)} maxLength={20} placeholder="optional" />
</div>
</div>
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="pf-bio">Bio</Label> <Label htmlFor="pf-bio">Bio</Label>
<Textarea id="pf-bio" value={bio} onChange={e => setBio(e.target.value)} maxLength={2000} rows={3} placeholder="optional" /> <Textarea id="pf-bio" value={bio} onChange={e => setBio(e.target.value)} maxLength={2000} rows={3} placeholder="optional" />
</div> </div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="pf-skills">Skills</Label> <span className={fieldLabel}>Skills</span>
<Input id="pf-skills" value={skills} onChange={e => setSkills(e.target.value)} placeholder="comma, separated" /> <ChipInput values={skills} onChange={setSkills} placeholder="add a skill and press enter" maxLength={60} maxItems={40} />
</div> </div>
<div className="space-y-1.5">
<Label htmlFor="pf-interests">Interests</Label>
<Input id="pf-interests" value={interests} onChange={e => setInterests(e.target.value)} placeholder="comma, separated" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pf-willing">Willing to</Label>
<Input id="pf-willing" value={willingTo} onChange={e => setWillingTo(e.target.value)} placeholder="comma, separated" />
</div>
<div className="space-y-1.5">
<Label htmlFor="pf-aff">Affiliations (conflict-of-interest)</Label>
<Input id="pf-aff" value={affiliations} onChange={e => setAffiliations(e.target.value)} placeholder="comma, separated" />
</div>
</div>
<div className="flex justify-end">
<Button size="sm" disabled={busy} onClick={save}>
{busy ? 'Saving…' : 'Save profile'}
</Button>
</div>
</div>
<div className="bg-card rounded border border-border p-4 space-y-3">
<div>
<p className="font-sans text-sm font-medium text-foreground">Login email</p>
<p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{initial.email || 'not set'}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="pf-email">New email</Label> <span className={fieldLabel}>Interests</span>
<Input <ChipInput values={interests} onChange={setInterests} placeholder="add an interest and press enter" maxLength={60} maxItems={40} />
id="pf-email" </div>
type="email"
value={newEmail} <div className="space-y-1.5">
onChange={e => setNewEmail(e.target.value)} <span className={fieldLabel}>Willing to do</span>
maxLength={254} <ChipInput
placeholder="[email protected]" values={willingTo}
onChange={setWillingTo}
placeholder="add a role and press enter"
maxLength={60}
maxItems={20}
suggestions={WILLING_TO_SUGGESTIONS}
/> />
<p className="font-sans text-xs text-muted-foreground">
Used by the board-only recruitment view when looking for members willing to take on a role.
</p>
</div>
<div className="space-y-1.5">
<span className={fieldLabel}>Affiliations (conflict-of-interest)</span>
<ChipInput values={affiliations} onChange={setAffiliations} placeholder="add an affiliation and press enter" maxLength={200} maxItems={50} />
</div>
<div className="flex justify-end">
<Button size="sm" disabled={busy} onClick={save}>
{busy ? 'Saving…' : 'Save profile'}
</Button>
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="font-sans text-xs text-muted-foreground"> <div className="bg-card rounded-lg border border-border p-5 space-y-3">
You must confirm via links sent to both your current and new address. <div>
</p> <p className="font-sans text-sm font-medium text-foreground">Login email</p>
<Button size="sm" variant="outline" disabled={emailBusy} onClick={changeEmail}> <p className="font-mono text-[10px] text-muted-foreground mt-0.5">
{emailBusy ? 'Sending…' : 'Change email'} {initial.email || 'not set'}
</Button> </p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="pf-email">New email</Label>
<Input
id="pf-email"
type="email"
value={newEmail}
onChange={e => setNewEmail(e.target.value)}
maxLength={254}
placeholder="[email protected]"
/>
</div>
</div>
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="font-sans text-xs text-muted-foreground">
You must confirm via links sent to both your current and new address.
</p>
<Button size="sm" variant="outline" disabled={emailBusy} onClick={changeEmail}>
{emailBusy ? 'Sending…' : 'Change email'}
</Button>
</div>
</div> </div>
</div> </div>
</div>
) )
} }
+1 -1
View File
@@ -3,7 +3,7 @@
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { discloseAffiliations } from '@/lib/actions' import { discloseAffiliations } from '@/lib/actions'
import { ChipInput } from './chip-input' import { ChipInput } from '@/components/chip-input'
export function AffiliationsForm({ initial }: { initial: { affiliations: string[] } }) { export function AffiliationsForm({ initial }: { initial: { affiliations: string[] } }) {
const router = useRouter() const router = useRouter()
+2 -11
View File
@@ -3,7 +3,8 @@
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { updateMyProfile } from '@/lib/actions' import { updateMyProfile } from '@/lib/actions'
import { ChipInput } from './chip-input' import { ChipInput } from '@/components/chip-input'
import { WILLING_TO_SUGGESTIONS } from '@/lib/profile-presets'
type Props = { type Props = {
initial: { initial: {
@@ -16,16 +17,6 @@ type Props = {
} }
} }
const WILLING_TO_SUGGESTIONS = [
'board_candidate',
'treasurer_candidate',
'host_volunteer',
'area_lead_candidate',
'event_organizer',
'safety_committee',
'docs_steward',
]
export function ProfileForm({ initial }: Props) { export function ProfileForm({ initial }: Props) {
const router = useRouter() const router = useRouter()
const [displayName, setDisplayName] = useState(initial.display_name) const [displayName, setDisplayName] = useState(initial.display_name)
+2
View File
@@ -1,6 +1,7 @@
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import SettingsClient from './settings-client' import SettingsClient from './settings-client'
import { StripeBillingPanel } from '@/components/settings/stripe-billing-panel' import { StripeBillingPanel } from '@/components/settings/stripe-billing-panel'
import { DuesPaymentMethodsPanel } from '@/components/settings/dues-payment-methods-panel'
import { redirect } from 'next/navigation' import { redirect } from 'next/navigation'
export default async function SettingsPage() { export default async function SettingsPage() {
@@ -34,6 +35,7 @@ export default async function SettingsPage() {
currentRole={member.role} currentRole={member.role}
/> />
<StripeBillingPanel spaceId={member.space_id} /> <StripeBillingPanel spaceId={member.space_id} />
<DuesPaymentMethodsPanel />
</> </>
) )
} }
+46 -2
View File
@@ -17,6 +17,11 @@ import { timingSafeEqual } from 'node:crypto'
import { createAdminClient } from '@/lib/supabase/admin' import { createAdminClient } from '@/lib/supabase/admin'
import { sendEmail } from '@/lib/email/send' import { sendEmail } from '@/lib/email/send'
import { isTerminalAttempt, MAX_NOTIFICATION_ATTEMPTS } from '@/lib/notifications-logic' import { isTerminalAttempt, MAX_NOTIFICATION_ATTEMPTS } from '@/lib/notifications-logic'
import {
isMuted,
type PrefMap,
type NotificationCategory,
} from '@/lib/notifications-prefs-logic'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -51,7 +56,7 @@ export async function POST(req: NextRequest) {
const admin = createAdminClient() const admin = createAdminClient()
const { data: candidates, error } = await admin const { data: candidates, error } = await admin
.from('notifications') .from('notifications')
.select('id, space_id, recipient, subject, body_html, body_text, attempts') .select('id, space_id, member_id, type, recipient, subject, body_html, body_text, attempts')
.eq('status', 'pending') .eq('status', 'pending')
.eq('channel', 'email') .eq('channel', 'email')
.lt('attempts', MAX_NOTIFICATION_ATTEMPTS) .lt('attempts', MAX_NOTIFICATION_ATTEMPTS)
@@ -87,11 +92,50 @@ export async function POST(req: NextRequest) {
} }
} }
// Per-member notification preferences for the rows we're about to drain.
// member_id is a space_members PK (globally unique), so keying by it alone is
// unambiguous across spaces. A row whose type maps to a category the member
// muted is marked 'skipped' (a terminal status) so it leaves the pending pool
// instead of being re-scanned every minute. Billing types and unmapped types
// are never muteable (see notifications-prefs-logic). member_id null (e.g. a
// future broadcast) has no member to have a preference, so it always sends.
const memberIds = Array.from(
new Set((rows ?? []).map(r => r.member_id as string | null).filter((m): m is string => !!m)),
)
const prefsByMember = new Map<string, PrefMap>()
if (memberIds.length > 0) {
const { data: prefRows, error: prefErr } = await admin
.from('notification_preferences')
.select('member_id, category, enabled')
.in('member_id', memberIds)
// Fail open: if the prefs lookup errors, send everything rather than risk
// silently dropping a wanted (e.g. dues-failure) email on a transient blip.
if (prefErr) console.error('[cron/notifications] prefs lookup failed:', prefErr.message)
for (const p of prefRows ?? []) {
const mid = p.member_id as string
if (!prefsByMember.has(mid)) prefsByMember.set(mid, {})
prefsByMember.get(mid)![p.category as NotificationCategory] = p.enabled as boolean
}
}
let sent = 0 let sent = 0
let failed = 0 let failed = 0
let retried = 0 let retried = 0
let skipped = 0
for (const row of rows ?? []) { for (const row of rows ?? []) {
const memberId = row.member_id as string | null
if (memberId && isMuted(prefsByMember.get(memberId) ?? {}, row.type as string)) {
// No send, no Resend call, no rate-limit spacing: muted rows are cheap.
await admin
.from('notifications')
.update({ status: 'skipped', last_error: null })
.eq('id', row.id)
.eq('status', 'pending')
skipped++
continue
}
const res = await sendEmail({ const res = await sendEmail({
to: row.recipient as string, to: row.recipient as string,
subject: row.subject as string, subject: row.subject as string,
@@ -135,5 +179,5 @@ export async function POST(req: NextRequest) {
if (SPACING_MS) await sleep(SPACING_MS) if (SPACING_MS) await sleep(SPACING_MS)
} }
return NextResponse.json({ scanned: rows?.length ?? 0, sent, failed, retried }) return NextResponse.json({ scanned: rows?.length ?? 0, sent, failed, retried, skipped })
} }
+80 -17
View File
@@ -5,25 +5,45 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { startDuesCheckout, startBillingPortal } from '@/lib/actions' import { startDuesCheckout, startBillingPortal } from '@/lib/actions'
import { DUES_PLATFORM_LABEL, type DuesLinkPlatform } from '@/lib/dues-payments-logic'
type Billing = { status: string | null; currentPeriodEnd: string | null; hasCustomer: boolean } | null type Billing = {
status: string | null
currentPeriodEnd: string | null
hasCustomer: boolean
configured: boolean
} | null
export type DuesMethod = {
platform: string
url: string
instructions: string | null
}
const PAID = new Set(['active', 'trialing']) const PAID = new Set(['active', 'trialing'])
export function DuesCard({ billing }: { billing: Billing }) { const platformLabel = (p: string) =>
DUES_PLATFORM_LABEL[p as DuesLinkPlatform] ?? p
export function DuesCard({ billing, methods = [] }: { billing: Billing; methods?: DuesMethod[] }) {
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const paid = !!billing?.status && PAID.has(billing.status) const paid = !!billing?.status && PAID.has(billing.status)
const stripeReady = !!billing?.configured
const hasAltMethods = methods.length > 0
async function go(fn: () => Promise<{ error?: string } | { data: { url: string } }>) { async function go(fn: () => Promise<{ error?: string } | { data: { url: string } }>) {
setBusy(true) setBusy(true)
const res = await fn() const res = await fn()
if ('error' in res && res.error) { setBusy(false); return toast.error(res.error) } if ('error' in res && res.error) {
setBusy(false)
return toast.error(res.error)
}
window.location.href = (res as { data: { url: string } }).data.url window.location.href = (res as { data: { url: string } }).data.url
} }
return ( return (
<div className="bg-card rounded border border-border p-4"> <div className="bg-card rounded-lg border border-border p-5 space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap"> <div className="flex items-start justify-between gap-3 flex-wrap">
<div> <div>
<p className="font-sans text-sm font-medium text-foreground">Membership dues</p> <p className="font-sans text-sm font-medium text-foreground">Membership dues</p>
{billing?.status ? ( {billing?.status ? (
@@ -41,18 +61,61 @@ export function DuesCard({ billing }: { billing: Billing }) {
<Badge variant={paid ? 'default' : 'outline'}>{paid ? 'Current' : 'Action needed'}</Badge> <Badge variant={paid ? 'default' : 'outline'}>{paid ? 'Current' : 'Action needed'}</Badge>
)} )}
</div> </div>
<div className="flex gap-2 mt-3">
{!paid && ( {/* Stripe Checkout / Portal: only when the space has Stripe set up. */}
<Button size="sm" disabled={busy} onClick={() => go(startDuesCheckout)}> {(stripeReady && (!paid || billing?.hasCustomer)) && (
{busy ? 'Opening…' : 'Pay dues'} <div className="flex gap-2">
</Button> {!paid && (
)} <Button size="sm" disabled={busy} onClick={() => go(startDuesCheckout)}>
{billing?.hasCustomer && ( {busy ? 'Opening…' : 'Pay dues with card'}
<Button size="sm" variant="outline" disabled={busy} onClick={() => go(startBillingPortal)}> </Button>
Manage billing )}
</Button> {billing?.hasCustomer && (
)} <Button size="sm" variant="outline" disabled={busy} onClick={() => go(startBillingPortal)}>
</div> Manage billing
</Button>
)}
</div>
)}
{/* Admin-configured external links. Shown when the member still owes dues. */}
{!paid && hasAltMethods && (
<div className="space-y-2">
<p className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{stripeReady ? 'Other ways to pay' : 'Pay your dues'}
</p>
<div className="flex flex-col gap-2">
{methods.map(m => (
<a
key={m.platform}
href={m.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between gap-3 rounded border border-border bg-background px-3 py-2 hover:border-primary transition"
>
<div className="min-w-0">
<span className="font-sans text-sm text-foreground">Pay with {platformLabel(m.platform)}</span>
{m.instructions && (
<p className="font-sans text-xs text-muted-foreground mt-0.5">{m.instructions}</p>
)}
</div>
<span className="font-mono text-[10px] text-muted-foreground shrink-0">open ↗</span>
</a>
))}
</div>
<p className="font-sans text-xs text-muted-foreground">
These open an external payment page. Your payment is recorded by a treasurer after it
clears.
</p>
</div>
)}
{/* Nothing set up at all. */}
{!paid && !stripeReady && !hasAltMethods && (
<p className="font-sans text-sm text-muted-foreground">
Online dues payment isn&rsquo;t set up for this space yet. Contact an admin to pay your dues.
</p>
)}
</div> </div>
) )
} }
@@ -0,0 +1,149 @@
'use client'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Switch } from '@/components/ui/switch'
import {
DUES_LINK_PLATFORMS,
DUES_PLATFORM_LABEL,
type DuesLinkPlatform,
} from '@/lib/dues-payments-logic'
import {
listDuesPaymentMethods,
upsertDuesPaymentMethod,
deleteDuesPaymentMethod,
} from '@/lib/actions'
const input =
'w-full bg-background border border-border text-foreground font-sans text-sm rounded px-3 py-2 focus:outline-none focus:border-primary'
type Row = { url: string; instructions: string; isActive: boolean; exists: boolean }
type RowMap = Record<DuesLinkPlatform, Row>
const emptyRows = (): RowMap =>
Object.fromEntries(
DUES_LINK_PLATFORMS.map(p => [p, { url: '', instructions: '', isActive: true, exists: false }]),
) as RowMap
export function DuesPaymentMethodsPanel() {
const [rows, setRows] = useState<RowMap | null>(null)
const [busy, setBusy] = useState<DuesLinkPlatform | null>(null)
async function load() {
const next = emptyRows()
const r = await listDuesPaymentMethods()
if ('data' in r) {
for (const m of r.data) {
const p = m.platform as DuesLinkPlatform
if (next[p]) next[p] = { url: m.url, instructions: m.instructions ?? '', isActive: m.isActive, exists: true }
}
}
setRows(next)
}
useEffect(() => {
load()
}, [])
if (!rows) return null
function set(p: DuesLinkPlatform, patch: Partial<Row>) {
setRows(prev => (prev ? { ...prev, [p]: { ...prev[p], ...patch } } : prev))
}
async function save(p: DuesLinkPlatform) {
const row = rows![p]
if (!row.url.trim()) return toast.error('Enter a payment URL first.')
setBusy(p)
const res = await upsertDuesPaymentMethod({
platform: p,
url: row.url.trim(),
instructions: row.instructions.trim() || null,
is_active: row.isActive,
sort_order: DUES_LINK_PLATFORMS.indexOf(p),
})
setBusy(null)
if ('error' in res) return toast.error(res.error)
toast.success(`${DUES_PLATFORM_LABEL[p]} saved`)
load()
}
async function remove(p: DuesLinkPlatform) {
setBusy(p)
const res = await deleteDuesPaymentMethod({ platform: p })
setBusy(null)
if ('error' in res) return toast.error(res.error)
toast.success(`${DUES_PLATFORM_LABEL[p]} removed`)
load()
}
return (
<div className="p-4 md:p-6 border-t border-border">
<div className="max-w-2xl space-y-3">
<h2 className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
Other ways to pay dues
</h2>
<p className="font-sans text-xs text-muted-foreground">
Add an external payment link per platform (PayPal, Zeffy, Venmo). Members see active links
on their membership page and pay you directly there. Payments are not recorded
automatically: reconcile them later in Payments.
</p>
<div className="space-y-4">
{DUES_LINK_PLATFORMS.map(p => {
const row = rows[p]
return (
<div key={p} className="rounded border border-border bg-background p-3 space-y-2">
<div className="flex items-center justify-between">
<span className="font-sans text-sm font-medium text-foreground">
{DUES_PLATFORM_LABEL[p]}
</span>
<label className="flex items-center gap-2">
<span className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
{row.isActive ? 'shown' : 'hidden'}
</span>
<Switch
checked={row.isActive}
onCheckedChange={next => set(p, { isActive: next })}
aria-label={`Show ${DUES_PLATFORM_LABEL[p]} to members`}
/>
</label>
</div>
<input
className={input}
value={row.url}
onChange={e => set(p, { url: e.target.value })}
placeholder="https://… payment link"
inputMode="url"
maxLength={500}
/>
<input
className={input}
value={row.instructions}
onChange={e => set(p, { instructions: e.target.value })}
placeholder="Instructions, e.g. put your member name in the note (optional)"
maxLength={300}
/>
<div className="flex gap-2">
<Button size="sm" disabled={busy === p} onClick={() => save(p)}>
{busy === p ? 'Saving…' : 'Save'}
</Button>
{row.exists && (
<Button
size="sm"
variant="outline"
disabled={busy === p}
onClick={() => remove(p)}
>
Remove
</Button>
)}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}
+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 the real cause is logged server-side. Phases 2-3 (transactional
notifications, broader self-serve) build on this. 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) ### Transactional notifications (migration 041; product spine Phase 2)
Outbox + dispatcher, not inline send. The Stripe webhook only writes a 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 permission-lookup failure can never throw into the calling action, so the
underlying domain mutation always finalizes. 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) ### 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. `/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. 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. 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`). 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. 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. 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 | | 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 | | 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 | | 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`. 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) ## 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. 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.
+124
View File
@@ -0,0 +1,124 @@
'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server'
import { requireMember, requireMemberWithRole, parseInput } from '@/lib/auth-helpers'
import { ADMIN_ROLES } from '@/lib/permissions'
import { duesPaymentMethodSchema } from '@/lib/validations'
export type DuesPaymentMethod = {
platform: string
url: string
instructions: string | null
isActive: boolean
sortOrder: number
}
// Member-facing: the ACTIVE external dues payment links for the caller's space,
// in display order. RLS (SELECT = space member) scopes this; we still pin
// space_id for clarity. Used by the /me dues UI to render click-out buttons.
export async function listActiveDuesPaymentMethods(): Promise<
{ data: DuesPaymentMethod[] } | { error: string }
> {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data } = await supabase
.from('dues_payment_methods')
.select('platform, url, instructions, is_active, sort_order')
.eq('space_id', member.space_id)
.eq('is_active', true)
.order('sort_order', { ascending: true })
return { data: (data ?? []).map(toMethod) }
}
// Admin-facing: every configured method (active + inactive) for the management
// UI in /settings.
export async function listDuesPaymentMethods(): Promise<
{ data: DuesPaymentMethod[] } | { error: string }
> {
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data } = await supabase
.from('dues_payment_methods')
.select('platform, url, instructions, is_active, sort_order')
.eq('space_id', member.space_id)
.order('sort_order', { ascending: true })
return { data: (data ?? []).map(toMethod) }
}
// Admin: create or update the link for one platform (idempotent on
// (space_id, platform)). The RLS write policies re-check the admin/board role.
export async function upsertDuesPaymentMethod(
input: unknown,
): Promise<{ ok: true } | { error: string }> {
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const v = parseInput(duesPaymentMethodSchema, input)
if (!v.ok) return { error: v.error }
const s = v.data
const { error } = await supabase.from('dues_payment_methods').upsert(
{
space_id: member.space_id,
platform: s.platform,
url: s.url,
instructions: s.instructions ?? null,
is_active: s.is_active ?? true,
sort_order: s.sort_order ?? 0,
},
{ onConflict: 'space_id,platform' },
)
if (error) return { error: 'Could not save the payment method.' }
revalidatePath('/settings')
revalidatePath('/me')
return { ok: true }
}
// Admin: remove the link for one platform entirely.
export async function deleteDuesPaymentMethod(input: {
platform: string
}): Promise<{ ok: true } | { error: string }> {
const supabase = await createClient()
const auth = await requireMemberWithRole(supabase, ADMIN_ROLES, 'Admin access required')
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { error } = await supabase
.from('dues_payment_methods')
.delete()
.eq('space_id', member.space_id)
.eq('platform', input.platform)
if (error) return { error: 'Could not remove the payment method.' }
revalidatePath('/settings')
revalidatePath('/me')
return { ok: true }
}
function toMethod(r: {
platform: string
url: string
instructions: string | null
is_active: boolean
sort_order: number
}): DuesPaymentMethod {
return {
platform: r.platform,
url: r.url,
instructions: r.instructions ?? null,
isActive: r.is_active,
sortOrder: r.sort_order,
}
}
+3
View File
@@ -71,3 +71,6 @@ export * from './stripe'
// Transactional notifications outbox (product spine Phase 2) // Transactional notifications outbox (product spine Phase 2)
export * from './notifications' export * from './notifications'
// Alternate dues payment methods (admin-configured external links)
export * from './dues-payments'
+67
View File
@@ -1,8 +1,15 @@
'use server' 'use server'
import { revalidatePath } from 'next/cache'
import { createClient } from '@/lib/supabase/server' import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin' import { createAdminClient } from '@/lib/supabase/admin'
import { requireMember } from '@/lib/auth-helpers' import { requireMember } from '@/lib/auth-helpers'
import { notificationPreferenceSchema } from '@/lib/validations'
import {
MUTEABLE_CATEGORIES,
type NotificationCategory,
type PrefMap,
} from '@/lib/notifications-prefs-logic'
// The caller's own notification history. The notifications RLS SELECT is // The caller's own notification history. The notifications RLS SELECT is
// admin/board/treasurer only (mirrors member_billing), so the member // admin/board/treasurer only (mirrors member_billing), so the member
@@ -32,3 +39,63 @@ export async function getMyNotifications() {
})), })),
} }
} }
// The caller's own notification preferences as a category -> enabled map.
// notification_preferences has no client policy (same convention as
// notifications / member_billing), so the read goes through the service client
// scoped to the caller's own member row. Absent categories are the default
// (enabled); only muteable categories are ever stored or returned.
export async function getMyNotificationPreferences(): Promise<{ data: PrefMap } | { error: string }> {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const { data } = await createAdminClient()
.from('notification_preferences')
.select('category, enabled')
.eq('space_id', member.space_id)
.eq('member_id', member.id)
const prefs: PrefMap = {}
for (const c of MUTEABLE_CATEGORIES) prefs[c] = true
for (const row of data ?? []) {
const cat = row.category as NotificationCategory
if ((MUTEABLE_CATEGORIES as readonly NotificationCategory[]).includes(cat)) {
prefs[cat] = row.enabled as boolean
}
}
return { data: prefs }
}
// Set one muteable category on/off for the caller. Billing is never settable
// (the Zod enum excludes it). Upserts the caller's own row through the service
// client; the (space_id, member_id, category) PK makes a re-toggle idempotent.
export async function setMyNotificationPreference(input: {
category: string
enabled: boolean
}): Promise<{ ok: true } | { error: string }> {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { error: auth.error }
const { member } = auth
const parsed = notificationPreferenceSchema.safeParse(input)
if (!parsed.success) return { error: 'Invalid notification preference' }
const { error } = await createAdminClient()
.from('notification_preferences')
.upsert(
{
space_id: member.space_id,
member_id: member.id,
category: parsed.data.category,
enabled: parsed.data.enabled,
},
{ onConflict: 'space_id,member_id,category' },
)
if (error) return { error: 'Could not save preference' }
revalidatePath('/me')
return { ok: true }
}
+13 -8
View File
@@ -220,20 +220,25 @@ export async function getMyBilling() {
if (!auth.ok) return { error: auth.error } if (!auth.ok) return { error: auth.error }
const { member } = auth const { member } = auth
const { data } = await createAdminClient() const admin = createAdminClient()
// Whether the space has Stripe dues set up at all, so the /me UI can hide the
// "Pay dues" (Stripe Checkout) button when there is no Stripe to check out to.
const cfg = await getStripeConfig(admin, member.space_id)
const configured = isStripeConfigured(cfg ?? {})
const { data } = await admin
.from('member_billing') .from('member_billing')
.select('subscription_status, current_period_end, stripe_customer_id') .select('subscription_status, current_period_end, stripe_customer_id')
.eq('space_id', member.space_id) .eq('space_id', member.space_id)
.eq('member_id', member.id) .eq('member_id', member.id)
.maybeSingle() .maybeSingle()
return { return {
data: data data: {
? { status: (data?.subscription_status as string | null) ?? null,
status: (data.subscription_status as string | null) ?? null, currentPeriodEnd: (data?.current_period_end as string | null) ?? null,
currentPeriodEnd: (data.current_period_end as string | null) ?? null, hasCustomer: !!data?.stripe_customer_id,
hasCustomer: !!data.stripe_customer_id, configured,
} },
: null,
} }
} }
+36
View File
@@ -0,0 +1,36 @@
// Pure logic for admin-configured alternate dues payment methods. These are
// external pay-here links (PayPal, Zeffy, Venmo) a member clicks to pay dues
// off-platform; a treasurer reconciles the payment manually later through the
// existing payments flow, which is why each method carries a payment_platform
// tag. No I/O here: the platform set + labels are shared by the admin settings
// UI and the member /me UI; URL validation is exercised by the Zod schema.
// The url-based subset of the payment_platform enum. 'cash' has no link and
// 'stripe' is the in-app integration, so neither is an external dues link.
export const DUES_LINK_PLATFORMS = ['paypal', 'zeffy', 'venmo'] as const
export type DuesLinkPlatform = (typeof DUES_LINK_PLATFORMS)[number]
export const DUES_PLATFORM_LABEL: Record<DuesLinkPlatform, string> = {
paypal: 'PayPal',
zeffy: 'Zeffy',
venmo: 'Venmo',
}
export function isDuesLinkPlatform(p: string): p is DuesLinkPlatform {
return (DUES_LINK_PLATFORMS as readonly string[]).includes(p)
}
// A payment link is only safe to render as a member-clickable anchor if it is
// an absolute https URL. http (plaintext) and any non-web scheme
// (javascript:, data:, mailto:, etc.) are rejected so an admin-entered value
// cannot become an XSS or a downgrade vector when shown to members.
export function isSafeDuesUrl(url: string): boolean {
let u: URL
try {
u = new URL(url)
} catch {
return false
}
return u.protocol === 'https:'
}
+93
View File
@@ -0,0 +1,93 @@
// Member notification preferences: the pure category model plus the single
// mute decision. No I/O, no Supabase. The dispatcher consumes isMuted to decide
// whether to send an outbox row; the /me UI consumes CATEGORY_META to render the
// toggles. The notifications.type column stays free text, so adding a new event
// type only needs a line in TYPE_CATEGORY here (and a renderer) -- the prefs
// storage and the dispatcher do not change.
export type NotificationCategory =
| 'billing'
| 'bookings'
| 'classes'
| 'forms'
| 'admin_alerts'
// Categories a member can mute. 'billing' is deliberately excluded: dues
// renewed / payment-failed / lapsed are membership-critical financial notices
// (a muted lapse notice would let a member silently lose access for
// non-payment), so they always send and never render a toggle.
export const MUTEABLE_CATEGORIES = [
'bookings',
'classes',
'forms',
'admin_alerts',
] as const satisfies readonly NotificationCategory[]
// Maps every emitted notification type to its category. A type with no entry
// here is treated as always-on by isMuted (fail-open: a member should still
// receive a newly added kind of mail rather than have it silently dropped by a
// missing mapping). Keep in sync with the renderers in notifications-logic.ts.
export const TYPE_CATEGORY: Record<string, NotificationCategory> = {
dues_renewed: 'billing',
dues_payment_failed: 'billing',
dues_lapsed: 'billing',
booking_confirmed: 'bookings',
booking_cancelled: 'bookings',
class_signup_registered: 'classes',
class_signup_waitlisted: 'classes',
class_signup_promoted: 'classes',
class_session_cancelled: 'classes',
form_submission_received: 'forms',
form_submission_admin: 'admin_alerts',
}
export function categoryForType(type: string): NotificationCategory | null {
return TYPE_CATEGORY[type] ?? null
}
export function isMuteableCategory(cat: NotificationCategory): boolean {
return (MUTEABLE_CATEGORIES as readonly NotificationCategory[]).includes(cat)
}
// Display order + copy for the /me toggles. Only muteable categories appear.
export const CATEGORY_META: Array<{
category: NotificationCategory
label: string
description: string
}> = [
{
category: 'bookings',
label: 'Equipment bookings',
description: 'Reservation confirmations and cancellations.',
},
{
category: 'classes',
label: 'Classes',
description: 'Signups, waitlist moves, and session cancellations.',
},
{
category: 'forms',
label: 'Your form submissions',
description: 'Receipts when you submit a form or waiver.',
},
{
category: 'admin_alerts',
label: 'Admin alerts',
description: 'New form submissions you can manage.',
},
]
// A member's stored preferences as a category -> enabled map. An absent
// category means the default (enabled): this is an opt-out model. Only
// muteable categories are ever stored; billing is never written.
export type PrefMap = Partial<Record<NotificationCategory, boolean>>
// The mute decision for one outbox row. Returns true (suppress the send) only
// when the type maps to a muteable category the member has explicitly disabled.
// Always-on categories (billing) and unmapped types never mute.
export function isMuted(prefs: PrefMap, type: string): boolean {
const cat = categoryForType(type)
if (!cat) return false
if (!isMuteableCategory(cat)) return false
return prefs[cat] === false
}
+12
View File
@@ -0,0 +1,12 @@
// Suggested values offered as click-to-add chips in the member profile editor.
// "Willing to" feeds the board-only recruitment view, so the suggestions are
// the governance/volunteer roles that view groups by.
export const WILLING_TO_SUGGESTIONS = [
'board_candidate',
'treasurer_candidate',
'host_volunteer',
'area_lead_candidate',
'event_organizer',
'safety_committee',
'docs_steward',
]
+30
View File
@@ -1,5 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { INVITE_ROLES } from './invite-logic' import { INVITE_ROLES } from './invite-logic'
import { MUTEABLE_CATEGORIES } from './notifications-prefs-logic'
import { DUES_LINK_PLATFORMS, isSafeDuesUrl } from './dues-payments-logic'
/** /**
* Accepts every date-ish string the app produces and normalizes to a * Accepts every date-ish string the app produces and normalizes to a
@@ -1021,6 +1023,34 @@ export const stripeSettingsSchema = z.object({
prices: z.record(z.string().trim().max(255)).optional().default({}), prices: z.record(z.string().trim().max(255)).optional().default({}),
}) })
// ─── Notification preferences ────────────────────────────────────────────────
// Only muteable categories are settable; billing is membership-critical and
// never user-controllable, so it is excluded from the enum (a request to set
// it is rejected at the boundary).
export const notificationPreferenceSchema = z.object({
category: z.enum(MUTEABLE_CATEGORIES as unknown as [string, ...string[]]),
enabled: z.boolean(),
})
// ─── Dues payment methods (admin-configured external links) ──────────────────
// One external pay-here URL per platform. url must be absolute https (no
// http downgrade, no javascript:/data: scheme) since it is rendered as a
// member-clickable anchor. instructions is an optional memo hint (e.g. "put
// your member name in the note") to help the treasurer reconcile later.
export const duesPaymentMethodSchema = z.object({
platform: z.enum(DUES_LINK_PLATFORMS as unknown as [string, ...string[]]),
url: z
.string()
.trim()
.max(500)
.refine(isSafeDuesUrl, 'Enter an absolute https:// URL'),
instructions: z.string().trim().max(300).optional().nullable(),
is_active: z.boolean().optional().default(true),
sort_order: z.number().int().min(0).max(999).optional().default(0),
})
// Helper type exports // Helper type exports
export type CreateTaskInput = z.infer<typeof createTaskSchema> export type CreateTaskInput = z.infer<typeof createTaskSchema>
export type CreateProjectInput = z.infer<typeof createProjectSchema> export type CreateProjectInput = z.infer<typeof createProjectSchema>
+41
View File
@@ -0,0 +1,41 @@
-- =============================================================================
-- 048: Member notification preferences (Product spine Phase 5)
-- =============================================================================
-- Per-member opt-out of muteable notification categories (bookings, classes,
-- forms, admin_alerts). Billing (dues renewed / payment-failed / lapsed) is
-- membership-critical and is never muteable, so it is never stored here. The
-- model is opt-out: absence of a row means the default, enabled.
--
-- The dispatcher cron reads these rows (service client) and marks a muted
-- outbox row 'skipped' instead of sending it; the member self-serve toggle on
-- /me writes them through a validated server action (service client). Both
-- paths use the service client, so there is NO client write policy and no
-- client read policy -- the same convention as notifications and
-- member_billing (member self-view goes through a validated action, not RLS).
-- RLS is enabled and default-deny for every non-service client.
--
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY/TRIGGER IF EXISTS
-- then CREATE. Apply as-is (Supabase SQL editor / psql); re-runs are no-ops.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.notification_preferences (
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
category text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (space_id, member_id, category)
);
ALTER TABLE public.notification_preferences ENABLE ROW LEVEL SECURITY;
-- No client policy. Both the member self-view (getMyNotificationPreferences)
-- and the toggle write (setMyNotificationPreference) go through validated
-- service-client actions; the dispatcher reads via the service client too.
-- Default-deny for every non-service client.
DROP TRIGGER IF EXISTS trg_notification_prefs_touch ON public.notification_preferences;
CREATE TRIGGER trg_notification_prefs_touch
BEFORE UPDATE ON public.notification_preferences
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+59
View File
@@ -0,0 +1,59 @@
-- =============================================================================
-- 049: Alternate dues payment methods (admin-configured external links)
-- =============================================================================
-- A space can take dues off-platform (PayPal, Zeffy, Venmo) in addition to (or
-- instead of) the integrated Stripe dues. An admin sets one external pay-here
-- URL per platform; members see them as click-out buttons on /me; a treasurer
-- reconciles the resulting payment manually through the existing payments flow.
-- Each row carries a payment_platform tag so that manual reconcile is
-- pre-typed. There is NO automated payment record on click: this table is link
-- configuration only.
--
-- One row per (space, platform): UNIQUE (space_id, platform) so the admin
-- upsert is idempotent. RLS: every space member may SELECT (members need to
-- render the pay options); writes are admin/board only (mirrors the Stripe
-- settings gate). url is validated https-only at the server-action boundary.
--
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY/TRIGGER IF EXISTS
-- then CREATE. Apply as-is (Supabase SQL editor / psql); re-runs are no-ops.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dues_payment_methods (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
platform public.payment_platform NOT NULL,
url text NOT NULL,
instructions text,
is_active boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, platform)
);
CREATE INDEX IF NOT EXISTS idx_dues_payment_methods_space
ON public.dues_payment_methods (space_id);
ALTER TABLE public.dues_payment_methods ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS dues_payment_methods_select ON public.dues_payment_methods;
DROP POLICY IF EXISTS dues_payment_methods_insert ON public.dues_payment_methods;
DROP POLICY IF EXISTS dues_payment_methods_update ON public.dues_payment_methods;
DROP POLICY IF EXISTS dues_payment_methods_delete ON public.dues_payment_methods;
-- Any member of the space may read (they render the pay buttons).
CREATE POLICY dues_payment_methods_select ON public.dues_payment_methods FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
-- Writes: admin/board only.
CREATE POLICY dues_payment_methods_insert ON public.dues_payment_methods FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY dues_payment_methods_update ON public.dues_payment_methods FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']))
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY dues_payment_methods_delete ON public.dues_payment_methods FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
DROP TRIGGER IF EXISTS trg_dues_payment_methods_touch ON public.dues_payment_methods;
CREATE TRIGGER trg_dues_payment_methods_touch
BEFORE UPDATE ON public.dues_payment_methods
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+71
View File
@@ -2774,3 +2774,74 @@ DROP TRIGGER IF EXISTS trg_notifications_touch ON public.notifications;
CREATE TRIGGER trg_notifications_touch CREATE TRIGGER trg_notifications_touch
BEFORE UPDATE ON public.notifications BEFORE UPDATE ON public.notifications
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
-- 26b. Member notification preferences (Product spine Phase 5)
-- (equivalent to scripts/048_notification_preferences.sql.)
-- Per-member opt-out of muteable categories (bookings, classes, forms,
-- admin_alerts). Billing is membership-critical and never muteable, so it is
-- never stored here. Opt-out model: absence of a row means enabled. The
-- dispatcher reads these (service client) and marks a muted outbox row
-- 'skipped' instead of sending; the /me toggle writes through a validated
-- service-client action. No client policy: default-deny, same convention as
-- notifications / member_billing.
CREATE TABLE IF NOT EXISTS public.notification_preferences (
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
category text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (space_id, member_id, category)
);
ALTER TABLE public.notification_preferences ENABLE ROW LEVEL SECURITY;
-- No client policy: member self-view + toggle write both go through validated
-- service-client actions; the dispatcher reads via the service client.
DROP TRIGGER IF EXISTS trg_notification_prefs_touch ON public.notification_preferences;
CREATE TRIGGER trg_notification_prefs_touch
BEFORE UPDATE ON public.notification_preferences
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
-- 26c. Alternate dues payment methods (admin-configured external links)
-- (equivalent to scripts/049_dues_payment_methods.sql.)
-- One external pay-here URL per (space, payment_platform): PayPal/Zeffy/Venmo.
-- Members click out to pay dues off-platform; 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); writes admin/board. url is validated
-- https-only at the server-action boundary.
CREATE TABLE IF NOT EXISTS public.dues_payment_methods (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
platform public.payment_platform NOT NULL,
url text NOT NULL,
instructions text,
is_active boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, platform)
);
CREATE INDEX IF NOT EXISTS idx_dues_payment_methods_space
ON public.dues_payment_methods (space_id);
ALTER TABLE public.dues_payment_methods ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS dues_payment_methods_select ON public.dues_payment_methods;
DROP POLICY IF EXISTS dues_payment_methods_insert ON public.dues_payment_methods;
DROP POLICY IF EXISTS dues_payment_methods_update ON public.dues_payment_methods;
DROP POLICY IF EXISTS dues_payment_methods_delete ON public.dues_payment_methods;
CREATE POLICY dues_payment_methods_select ON public.dues_payment_methods FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY dues_payment_methods_insert ON public.dues_payment_methods FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY dues_payment_methods_update ON public.dues_payment_methods FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']))
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY dues_payment_methods_delete ON public.dues_payment_methods FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
DROP TRIGGER IF EXISTS trg_dues_payment_methods_touch ON public.dues_payment_methods;
CREATE TRIGGER trg_dues_payment_methods_touch
BEFORE UPDATE ON public.dues_payment_methods
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+12
View File
@@ -196,6 +196,18 @@ export type Database = {
Update: { id?: string; space_id?: string; member_id?: string | null; type?: string; channel?: string; recipient?: string; subject?: string; body_html?: string; body_text?: string; status?: string; attempts?: number; last_error?: string | null; dedupe_key?: string; sent_at?: string | null; created_at?: string; updated_at?: string } Update: { id?: string; space_id?: string; member_id?: string | null; type?: string; channel?: string; recipient?: string; subject?: string; body_html?: string; body_text?: string; status?: string; attempts?: number; last_error?: string | null; dedupe_key?: string; sent_at?: string | null; created_at?: string; updated_at?: string }
Relationships: [] Relationships: []
} }
notification_preferences: {
Row: { space_id: string; member_id: string; category: string; enabled: boolean; created_at: string; updated_at: string }
Insert: { space_id: string; member_id: string; category: string; enabled?: boolean; created_at?: string; updated_at?: string }
Update: { space_id?: string; member_id?: string; category?: string; enabled?: boolean; created_at?: string; updated_at?: string }
Relationships: []
}
dues_payment_methods: {
Row: { id: string; space_id: string; platform: string; url: string; instructions: string | null; is_active: boolean; sort_order: number; created_at: string; updated_at: string }
Insert: { id?: string; space_id: string; platform: string; url: string; instructions?: string | null; is_active?: boolean; sort_order?: number; created_at?: string; updated_at?: string }
Update: { id?: string; space_id?: string; platform?: string; url?: string; instructions?: string | null; is_active?: boolean; sort_order?: number; created_at?: string; updated_at?: string }
Relationships: []
}
activity_log: { activity_log: {
Row: { Row: {
action: string action: string