Files
hackerspace.sh/lib/notifications-prefs-logic.ts
Moheeb Zara ab8c22ae1d 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.
2026-05-19 20:26:32 -07:00

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
}