Migration 041 + schema.sql section 26: notifications outbox (service-only writer, admin/board/treasurer SELECT, member self-view via validated service action like member_billing; (space_id, dedupe_key) unique makes the webhook enqueue idempotent). lib/notifications-logic.ts is pure: deterministic dedupe keys, dues email rendering (HTML-escaped, brand- neutral), terminal-attempt rule. 13 tests. Inert until P2c wires it.
53 lines
2.9 KiB
SQL
53 lines
2.9 KiB
SQL
-- =============================================================================
|
|
-- 041: Transactional notifications outbox (Product spine Phase 2)
|
|
-- =============================================================================
|
|
-- Dues-lifecycle emails (renewal receipt, payment-failed, lapse-to-late) are
|
|
-- enqueued by the Stripe webhook and sent by a separate dispatcher cron. The
|
|
-- webhook NEVER sends inline: it only writes an idempotent outbox row, so the
|
|
-- money path stays fast and safe to retry (a duplicate enqueue is a no-op via
|
|
-- the (space_id, dedupe_key) unique index).
|
|
--
|
|
-- * notifications: one row per message. Service client (webhook enqueue +
|
|
-- dispatcher) is the only writer; SELECT = admin/board/treasurer (mirrors
|
|
-- member_billing / payments visibility). Member self-view of their own
|
|
-- notifications is a validated service-client action, not an RLS path
|
|
-- (same convention as getMyBilling).
|
|
--
|
|
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then
|
|
-- CREATE. Apply as-is (Supabase SQL editor / psql); re-runs are no-ops.
|
|
-- =============================================================================
|
|
|
|
CREATE TABLE IF NOT EXISTS public.notifications (
|
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
|
member_id uuid REFERENCES public.space_members(id) ON DELETE CASCADE,
|
|
type text NOT NULL,
|
|
channel text NOT NULL DEFAULT 'email',
|
|
recipient text NOT NULL,
|
|
subject text NOT NULL,
|
|
body_html text NOT NULL,
|
|
body_text text NOT NULL,
|
|
status text NOT NULL DEFAULT 'pending',
|
|
attempts integer NOT NULL DEFAULT 0,
|
|
last_error text,
|
|
dedupe_key text NOT NULL,
|
|
sent_at timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_notifications_dedupe ON public.notifications (space_id, dedupe_key);
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_pending ON public.notifications (created_at) WHERE status = 'pending';
|
|
CREATE INDEX IF NOT EXISTS idx_notifications_member ON public.notifications (space_id, member_id);
|
|
|
|
ALTER TABLE public.notifications ENABLE ROW LEVEL SECURITY;
|
|
DROP POLICY IF EXISTS notifications_select ON public.notifications;
|
|
CREATE POLICY notifications_select ON public.notifications FOR SELECT
|
|
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
|
|
-- No INSERT/UPDATE/DELETE policy: the validated service-client webhook enqueue
|
|
-- and dispatcher cron are the only writers.
|
|
|
|
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();
|