feat(classes): schema + RLS + permissions (data layer)

Migration 032 adds classes (offering; optional payment_link and
grants_certification_id, is_active archive), class_sessions (scheduled
occurrence; denormalized space_id) and class_signups (member signup;
partial unique = one non-cancelled signup per member+session). RLS is
additive and default-deny: classes readable by managers (all) or members
(active only), class_sessions by any space member, class_signups by
managers/instructors (all) or the member (own) with instructor-only
UPDATE and no INSERT/DELETE policy so signup/cancel funnels through a
validated service-client action (capacity/waitlist/dedupe). New
classes.manage + classes.instruct permissions (group Classes) seeded to
board and backfilled. Mirrored idempotently in schema.sql; types and
schema docs updated.
This commit is contained in:
Moheeb Zara
2026-05-16 19:27:32 -07:00
parent f783541a87
commit 0c028f52cc
6 changed files with 360 additions and 2 deletions
+137
View File
@@ -1828,6 +1828,7 @@ BEGIN
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
('board','certifications.manage'),('board','certifications.grant'),
('board','classes.manage'),('board','classes.instruct'),
('board','customize.manage'),('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'),
@@ -2083,3 +2084,139 @@ ON CONFLICT (space_id, subject, permission) DO NOTHING;
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT id, 'board', 'certifications.grant' FROM public.spaces
ON CONFLICT (space_id, subject, permission) DO NOTHING;
-- =============================================================================
-- 19. Classes (offerings, scheduled sessions, member signups)
-- (equivalent to scripts/032_classes.sql).
--
-- classes (offering; optional payment_link, capacity, is_active archive,
-- grants_certification_id -> certifications), class_sessions (scheduled
-- occurrence; space_id denormalized), class_signups (member signup;
-- space_id denormalized; partial unique = one non-cancelled signup per
-- member per session). Permissions classes.manage / classes.instruct
-- (group Classes). RLS additive/default-deny: classes SELECT = manager
-- (all) or member (is_active); class_sessions SELECT = any space member;
-- class_signups SELECT = manage/instruct (all) or member (own), UPDATE =
-- classes.instruct, NO INSERT/DELETE policy (signup/cancel funnels
-- through a validated service-client action). No anonymous path.
-- seed_default_role_permissions() above already seeds both codes; the
-- backfill below covers existing spaces.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.classes (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
description text,
payment_link text CHECK (payment_link IS NULL OR payment_link ~ '^https?://'),
capacity integer CHECK (capacity IS NULL OR capacity > 0),
is_active boolean NOT NULL DEFAULT true,
grants_certification_id uuid REFERENCES public.certifications(id) ON DELETE SET NULL,
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_classes_space ON public.classes (space_id, is_active);
CREATE TABLE IF NOT EXISTS public.class_sessions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
class_id uuid NOT NULL REFERENCES public.classes(id) ON DELETE CASCADE,
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
starts_at timestamptz NOT NULL,
ends_at timestamptz CHECK (ends_at IS NULL OR ends_at >= starts_at),
location text,
capacity integer CHECK (capacity IS NULL OR capacity > 0),
status text NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled','cancelled','completed')),
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_class_sessions_space ON public.class_sessions (space_id, starts_at);
CREATE INDEX IF NOT EXISTS idx_class_sessions_class ON public.class_sessions (class_id);
CREATE TABLE IF NOT EXISTS public.class_signups (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
session_id uuid NOT NULL REFERENCES public.class_sessions(id) ON DELETE CASCADE,
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,
status text NOT NULL DEFAULT 'registered'
CHECK (status IN ('registered','waitlisted','cancelled')),
attended boolean NOT NULL DEFAULT false,
signed_up_at timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_class_signups_active
ON public.class_signups (session_id, member_id)
WHERE status <> 'cancelled';
CREATE INDEX IF NOT EXISTS idx_class_signups_space ON public.class_signups (space_id);
CREATE INDEX IF NOT EXISTS idx_class_signups_session ON public.class_signups (session_id);
CREATE INDEX IF NOT EXISTS idx_class_signups_member ON public.class_signups (member_id);
ALTER TABLE public.classes ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.class_sessions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.class_signups ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS classes_select ON public.classes;
DROP POLICY IF EXISTS classes_insert ON public.classes;
DROP POLICY IF EXISTS classes_update ON public.classes;
DROP POLICY IF EXISTS classes_delete ON public.classes;
CREATE POLICY classes_select ON public.classes FOR SELECT
USING (
public.user_has_permission(auth.uid(), space_id, 'classes.manage')
OR (
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND is_active
)
);
CREATE POLICY classes_insert ON public.classes FOR INSERT
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'classes.manage'));
CREATE POLICY classes_update ON public.classes FOR UPDATE
USING (public.user_has_permission(auth.uid(), space_id, 'classes.manage'));
CREATE POLICY classes_delete ON public.classes FOR DELETE
USING (public.user_has_permission(auth.uid(), space_id, 'classes.manage'));
DROP POLICY IF EXISTS class_sessions_select ON public.class_sessions;
DROP POLICY IF EXISTS class_sessions_insert ON public.class_sessions;
DROP POLICY IF EXISTS class_sessions_update ON public.class_sessions;
DROP POLICY IF EXISTS class_sessions_delete ON public.class_sessions;
CREATE POLICY class_sessions_select ON public.class_sessions FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY class_sessions_insert ON public.class_sessions FOR INSERT
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'classes.manage'));
CREATE POLICY class_sessions_update ON public.class_sessions FOR UPDATE
USING (public.user_has_permission(auth.uid(), space_id, 'classes.manage'));
CREATE POLICY class_sessions_delete ON public.class_sessions FOR DELETE
USING (public.user_has_permission(auth.uid(), space_id, 'classes.manage'));
DROP POLICY IF EXISTS class_signups_select ON public.class_signups;
DROP POLICY IF EXISTS class_signups_update ON public.class_signups;
CREATE POLICY class_signups_select ON public.class_signups FOR SELECT
USING (
public.user_has_permission(auth.uid(), space_id, 'classes.manage')
OR public.user_has_permission(auth.uid(), space_id, 'classes.instruct')
OR member_id IN (
SELECT id FROM public.space_members
WHERE user_id = auth.uid() AND space_id = class_signups.space_id
)
);
CREATE POLICY class_signups_update ON public.class_signups FOR UPDATE
USING (public.user_has_permission(auth.uid(), space_id, 'classes.instruct'));
-- No INSERT/DELETE policy: signup/cancel via a validated service-client action.
DROP TRIGGER IF EXISTS trg_classes_touch ON public.classes;
CREATE TRIGGER trg_classes_touch
BEFORE UPDATE ON public.classes
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
DROP TRIGGER IF EXISTS trg_class_sessions_touch ON public.class_sessions;
CREATE TRIGGER trg_class_sessions_touch
BEFORE UPDATE ON public.class_sessions
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT id, 'board', 'classes.manage' FROM public.spaces
ON CONFLICT (space_id, subject, permission) DO NOTHING;
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT id, 'board', 'classes.instruct' FROM public.spaces
ON CONFLICT (space_id, subject, permission) DO NOTHING;