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:
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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’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
@@ -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.
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:'
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
]
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user