Add notification preferences and alternate dues payment options

Notification preferences (migration 048):
- Per-member opt-out of muteable categories (bookings, classes, forms,
  admin alerts); billing is always-on. Opt-out model, default on.
- Dispatcher reads prefs and marks muted outbox rows 'skipped' instead
  of sending; pure isMuted logic, fail-open on a prefs lookup error.
- notification_preferences table (default-deny RLS); /me toggles and
  self-view via validated service-client actions.

Alternate dues payment methods (migration 049):
- Admin sets external pay-here URLs per platform (PayPal/Zeffy/Venmo)
  in /settings; members see active ones on /me and pay off-platform;
  the treasurer reconciles manually (platform pre-tagged). https-only.
- dues_payment_methods table (SELECT space member, write admin/board).

/me visual pass:
- Restore the chip/preset profile editor (skills/interests/willing-to/
  affiliations); promote ChipInput and presets to shared modules.
- Gate the Stripe pay-dues button on whether the space has Stripe
  configured; show external links or a contact-admin note otherwise.
This commit is contained in:
Moheeb Zara
2026-05-19 20:26:32 -07:00
parent addedfdb0f
commit ab8c22ae1d
29 changed files with 1281 additions and 124 deletions
+41
View File
@@ -0,0 +1,41 @@
-- =============================================================================
-- 048: Member notification preferences (Product spine Phase 5)
-- =============================================================================
-- Per-member opt-out of muteable notification categories (bookings, classes,
-- forms, admin_alerts). Billing (dues renewed / payment-failed / lapsed) is
-- membership-critical and is never muteable, so it is never stored here. The
-- model is opt-out: absence of a row means the default, enabled.
--
-- The dispatcher cron reads these rows (service client) and marks a muted
-- outbox row 'skipped' instead of sending it; the member self-serve toggle on
-- /me writes them through a validated server action (service client). Both
-- paths use the service client, so there is NO client write policy and no
-- client read policy -- the same convention as notifications and
-- member_billing (member self-view goes through a validated action, not RLS).
-- RLS is enabled and default-deny for every non-service client.
--
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY/TRIGGER IF EXISTS
-- then CREATE. Apply as-is (Supabase SQL editor / psql); re-runs are no-ops.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.notification_preferences (
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
category text NOT NULL,
enabled boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (space_id, member_id, category)
);
ALTER TABLE public.notification_preferences ENABLE ROW LEVEL SECURITY;
-- No client policy. Both the member self-view (getMyNotificationPreferences)
-- and the toggle write (setMyNotificationPreference) go through validated
-- service-client actions; the dispatcher reads via the service client too.
-- Default-deny for every non-service client.
DROP TRIGGER IF EXISTS trg_notification_prefs_touch ON public.notification_preferences;
CREATE TRIGGER trg_notification_prefs_touch
BEFORE UPDATE ON public.notification_preferences
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+59
View File
@@ -0,0 +1,59 @@
-- =============================================================================
-- 049: Alternate dues payment methods (admin-configured external links)
-- =============================================================================
-- A space can take dues off-platform (PayPal, Zeffy, Venmo) in addition to (or
-- instead of) the integrated Stripe dues. An admin sets one external pay-here
-- URL per platform; members see them as click-out buttons on /me; a treasurer
-- reconciles the resulting payment manually through the existing payments flow.
-- Each row carries a payment_platform tag so that manual reconcile is
-- pre-typed. There is NO automated payment record on click: this table is link
-- configuration only.
--
-- One row per (space, platform): UNIQUE (space_id, platform) so the admin
-- upsert is idempotent. RLS: every space member may SELECT (members need to
-- render the pay options); writes are admin/board only (mirrors the Stripe
-- settings gate). url is validated https-only at the server-action boundary.
--
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY/TRIGGER IF EXISTS
-- then CREATE. Apply as-is (Supabase SQL editor / psql); re-runs are no-ops.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.dues_payment_methods (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
platform public.payment_platform NOT NULL,
url text NOT NULL,
instructions text,
is_active boolean NOT NULL DEFAULT true,
sort_order integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, platform)
);
CREATE INDEX IF NOT EXISTS idx_dues_payment_methods_space
ON public.dues_payment_methods (space_id);
ALTER TABLE public.dues_payment_methods ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS dues_payment_methods_select ON public.dues_payment_methods;
DROP POLICY IF EXISTS dues_payment_methods_insert ON public.dues_payment_methods;
DROP POLICY IF EXISTS dues_payment_methods_update ON public.dues_payment_methods;
DROP POLICY IF EXISTS dues_payment_methods_delete ON public.dues_payment_methods;
-- Any member of the space may read (they render the pay buttons).
CREATE POLICY dues_payment_methods_select ON public.dues_payment_methods FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
-- Writes: admin/board only.
CREATE POLICY dues_payment_methods_insert ON public.dues_payment_methods FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY dues_payment_methods_update ON public.dues_payment_methods FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']))
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY dues_payment_methods_delete ON public.dues_payment_methods FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
DROP TRIGGER IF EXISTS trg_dues_payment_methods_touch ON public.dues_payment_methods;
CREATE TRIGGER trg_dues_payment_methods_touch
BEFORE UPDATE ON public.dues_payment_methods
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
+71
View File
@@ -2774,3 +2774,74 @@ DROP TRIGGER IF EXISTS trg_notifications_touch ON public.notifications;
CREATE TRIGGER trg_notifications_touch
BEFORE UPDATE ON public.notifications
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();