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.
94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
// 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
|
|
}
|