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:
+5
-1
@@ -397,10 +397,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
|
|||||||
| 029 | `space_invites.role` (member_role enum, default `member`); invites can grant a role; admin-granting invites are admin-only (app-enforced) |
|
| 029 | `space_invites.role` (member_role enum, default `member`); invites can grant a role; admin-granting invites are admin-only (app-enforced) |
|
||||||
| 030 | `certifications`, `member_certifications` + `certifications.manage` / `certifications.grant` permissions (the latter = Instructor). Additive default-deny RLS; grants soft-revoked + immutable (no DELETE policy); expiry snapshotted at grant; no anonymous path |
|
| 030 | `certifications`, `member_certifications` + `certifications.manage` / `certifications.grant` permissions (the latter = Instructor). Additive default-deny RLS; grants soft-revoked + immutable (no DELETE policy); expiry snapshotted at grant; no anonymous path |
|
||||||
| 031 | `secrets_select` additively also honors `ops.secrets.read` (previously admin/board OR per-secret `ops_acl` only). Reveal/list gates let RLS decide; writes unchanged. Access-neutral unless the permission is granted |
|
| 031 | `secrets_select` additively also honors `ops.secrets.read` (previously admin/board OR per-secret `ops_acl` only). Reveal/list gates let RLS decide; writes unchanged. Access-neutral unless the permission is granted |
|
||||||
|
| 032 | `classes`, `class_sessions`, `class_signups` + `classes.manage` / `classes.instruct` permissions. Additive default-deny RLS; signup/cancel via validated service-client action (no INSERT/DELETE policy); one non-cancelled signup per member+session; optional cert-on-completion via the normal certifications path |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Tables added by migrations 016-030 (quick map)
|
## Tables added by migrations 016-032 (quick map)
|
||||||
|
|
||||||
| Table | Key columns | Purpose |
|
| Table | Key columns | Purpose |
|
||||||
|-------|-------------|---------|
|
|-------|-------------|---------|
|
||||||
@@ -421,6 +422,9 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
|
|||||||
| `form_submissions` | form_id, space_id, member_id, submitter_email, answers jsonb, form_snapshot jsonb, legal_text_snapshot, form_version, ip, user_agent | Append-only submissions. Per-row snapshot = immutable waiver record. SELECT = `forms.manage`; NO write policy (service-client only; immutable) |
|
| `form_submissions` | form_id, space_id, member_id, submitter_email, answers jsonb, form_snapshot jsonb, legal_text_snapshot, form_version, ip, user_agent | Append-only submissions. Per-row snapshot = immutable waiver record. SELECT = `forms.manage`; NO write policy (service-client only; immutable) |
|
||||||
| `certifications` | space_id, name (UNIQUE per space, case-insensitive), description, validity_months (null = never expires), is_active | Per-space certification types. SELECT = any space member; write-gated by `certifications.manage` |
|
| `certifications` | space_id, name (UNIQUE per space, case-insensitive), description, validity_months (null = never expires), is_active | Per-space certification types. SELECT = any space member; write-gated by `certifications.manage` |
|
||||||
| `member_certifications` | space_id, member_id, certification_id, granted_by, granted_at, expires_at (snapshotted at grant), revoked_at, revoked_by, revoked_reason, note | Per-member grants. Partial UNIQUE = one active grant per member+cert. SELECT = `certifications.manage`/`certifications.grant` (all) or member (own); INSERT/UPDATE = `certifications.grant`; NO DELETE policy (immutable history) |
|
| `member_certifications` | space_id, member_id, certification_id, granted_by, granted_at, expires_at (snapshotted at grant), revoked_at, revoked_by, revoked_reason, note | Per-member grants. Partial UNIQUE = one active grant per member+cert. SELECT = `certifications.manage`/`certifications.grant` (all) or member (own); INSERT/UPDATE = `certifications.grant`; NO DELETE policy (immutable history) |
|
||||||
|
| `classes` | space_id, title, description, payment_link (http(s) only), capacity, is_active, grants_certification_id -> certifications | Class offering. SELECT = `classes.manage` (all) or member (`is_active`); writes = `classes.manage` |
|
||||||
|
| `class_sessions` | class_id, space_id, starts_at, ends_at, location, capacity, status (scheduled/cancelled/completed), notes | Scheduled occurrence. SELECT = any space member; writes = `classes.manage` |
|
||||||
|
| `class_signups` | session_id, space_id, member_id, status (registered/waitlisted/cancelled), attended, signed_up_at | Member signup. Partial UNIQUE = one non-cancelled signup per member+session. SELECT = manage/instruct (all) or member (own); UPDATE = `classes.instruct`; NO INSERT/DELETE policy (signup/cancel via validated service-client action) |
|
||||||
|
|
||||||
Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`.
|
Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`.
|
||||||
|
|
||||||
|
|||||||
@@ -686,7 +686,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Migrations 014-031 (additions since the 13-table baseline)
|
## Migrations 014-032 (additions since the 13-table baseline)
|
||||||
|
|
||||||
`scripts/schema.sql` is the canonical idempotent schema; each numbered
|
`scripts/schema.sql` is the canonical idempotent schema; each numbered
|
||||||
migration is mirrored as a section in it. Tables/columns added:
|
migration is mirrored as a section in it. Tables/columns added:
|
||||||
@@ -711,6 +711,7 @@ migration is mirrored as a section in it. Tables/columns added:
|
|||||||
| 029 | `space_invites.role` (member_role enum, default `member`) so an invite can grant a chosen role; legacy invites and the spaces.invite_code path keep granting `member`. Who may create an admin-granting invite is enforced in-app (only an admin) |
|
| 029 | `space_invites.role` (member_role enum, default `member`) so an invite can grant a chosen role; legacy invites and the spaces.invite_code path keep granting `member`. Who may create an admin-granting invite is enforced in-app (only an admin) |
|
||||||
| 030 | `certifications` (per-space cert types; optional `validity_months`; `is_active` archive) + `member_certifications` (per-member grants; `expires_at` snapshotted at grant time; soft `revoked_at`/`revoked_by`; partial unique on one active grant per member+cert). RLS additive + default-deny: certifications SELECT = any space member, write = `certifications.manage`; member_certifications SELECT = `certifications.manage`/`certifications.grant` (all) or member (own), INSERT/UPDATE = `certifications.grant`, NO DELETE policy (history immutable). New `certifications.manage` + `certifications.grant` permissions (the latter = the Instructor capability) seeded to board + backfilled. No anonymous path |
|
| 030 | `certifications` (per-space cert types; optional `validity_months`; `is_active` archive) + `member_certifications` (per-member grants; `expires_at` snapshotted at grant time; soft `revoked_at`/`revoked_by`; partial unique on one active grant per member+cert). RLS additive + default-deny: certifications SELECT = any space member, write = `certifications.manage`; member_certifications SELECT = `certifications.manage`/`certifications.grant` (all) or member (own), INSERT/UPDATE = `certifications.grant`, NO DELETE policy (history immutable). New `certifications.manage` + `certifications.grant` permissions (the latter = the Instructor capability) seeded to board + backfilled. No anonymous path |
|
||||||
| 031 | `secrets_select` additively also honors the `ops.secrets.read` role permission (was admin/board OR per-secret `ops_acl` only; the permission was never consulted). Access-neutral unless an admin has granted the permission. Reveal/list server-side gates updated to let RLS be the boundary; write paths unchanged |
|
| 031 | `secrets_select` additively also honors the `ops.secrets.read` role permission (was admin/board OR per-secret `ops_acl` only; the permission was never consulted). Access-neutral unless an admin has granted the permission. Reveal/list server-side gates updated to let RLS be the boundary; write paths unchanged |
|
||||||
|
| 032 | `classes`, `class_sessions`, `class_signups` (class offerings, scheduled sessions, member signups; optional `payment_link` and `grants_certification_id`; partial unique = one non-cancelled signup per member+session). RLS additive + default-deny: classes SELECT = `classes.manage` (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 via a validated service-client action enforcing capacity/waitlist/dedupe). New `classes.manage` + `classes.instruct` permissions seeded to board + backfilled. Cert-on-completion uses the normal certifications path (still needs `certifications.grant`). No anonymous path |
|
||||||
|
|
||||||
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
|
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export const PERMISSIONS = [
|
|||||||
{ code: 'forms.manage', group: 'Community', label: 'Manage forms and waivers' },
|
{ code: 'forms.manage', group: 'Community', label: 'Manage forms and waivers' },
|
||||||
{ code: 'certifications.manage', group: 'Certifications', label: 'Manage certification types' },
|
{ code: 'certifications.manage', group: 'Certifications', label: 'Manage certification types' },
|
||||||
{ code: 'certifications.grant', group: 'Certifications', label: 'Award and revoke certifications (Instructor)' },
|
{ code: 'certifications.grant', group: 'Certifications', label: 'Award and revoke certifications (Instructor)' },
|
||||||
|
{ code: 'classes.manage', group: 'Classes', label: 'Manage classes and schedule sessions' },
|
||||||
|
{ code: 'classes.instruct', group: 'Classes', label: 'Run classes: attendance, completion, attendees' },
|
||||||
{ code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' },
|
{ code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' },
|
||||||
{ code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
|
{ code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
|
||||||
] as const
|
] as const
|
||||||
@@ -44,6 +46,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, PermissionCode[]> = {
|
|||||||
'members.manage', 'payments.manage', 'governance.manage',
|
'members.manage', 'payments.manage', 'governance.manage',
|
||||||
'forum.moderate', 'forms.manage',
|
'forum.moderate', 'forms.manage',
|
||||||
'certifications.manage', 'certifications.grant',
|
'certifications.manage', 'certifications.grant',
|
||||||
|
'classes.manage', 'classes.instruct',
|
||||||
'customize.manage', 'settings.manage',
|
'customize.manage', 'settings.manage',
|
||||||
],
|
],
|
||||||
treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
|
treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
-- =============================================================================
|
||||||
|
-- 032: Classes (offerings, scheduled sessions, member signups)
|
||||||
|
-- =============================================================================
|
||||||
|
-- Three tables:
|
||||||
|
--
|
||||||
|
-- classes a per-space class offering: title, description, an
|
||||||
|
-- optional generic payment_link (no live payment
|
||||||
|
-- integration; manual link only), an optional default
|
||||||
|
-- capacity, an is_active archive flag, and an optional
|
||||||
|
-- grants_certification_id so completing the class can award
|
||||||
|
-- a certification (module 030).
|
||||||
|
-- class_sessions a scheduled occurrence of a class (starts/ends, location,
|
||||||
|
-- capacity override, status scheduled/cancelled/completed).
|
||||||
|
-- space_id is denormalized for RLS without a join.
|
||||||
|
-- class_signups a member's signup for a session (registered/waitlisted/
|
||||||
|
-- cancelled, attended). space_id denormalized. A partial
|
||||||
|
-- unique index allows one non-cancelled signup per member
|
||||||
|
-- per session (re-signup after cancel is allowed).
|
||||||
|
--
|
||||||
|
-- Two new permission codes (lib/permissions-catalog.ts, group "Classes"):
|
||||||
|
--
|
||||||
|
-- classes.manage create/edit/archive classes; schedule/cancel sessions.
|
||||||
|
-- classes.instruct run a class: mark attendance, complete a session, see
|
||||||
|
-- the attendee list.
|
||||||
|
--
|
||||||
|
-- Member signup needs no permission, only space membership (same stance as
|
||||||
|
-- forms/onboarding). Cert-on-completion is issued through the normal
|
||||||
|
-- certifications path and therefore still requires the acting instructor to
|
||||||
|
-- hold certifications.grant; the application skips the award (and says so)
|
||||||
|
-- when they do not. No service-role bypass of the guarded certifications
|
||||||
|
-- surface.
|
||||||
|
--
|
||||||
|
-- RLS posture (additive, default-deny; the guarded surface):
|
||||||
|
--
|
||||||
|
-- * classes SELECT: classes.manage holders see all; ordinary members see
|
||||||
|
-- only is_active (mirrors forms_select). Writes: classes.manage.
|
||||||
|
-- * class_sessions SELECT: any space member (the schedule is not
|
||||||
|
-- sensitive; the UI filters cancelled/past). Writes: classes.manage.
|
||||||
|
-- * class_signups SELECT: classes.manage OR classes.instruct (all in
|
||||||
|
-- space) OR the member (own rows). UPDATE: classes.instruct (attendance
|
||||||
|
-- / status). NO INSERT or DELETE policy: member signup/cancel funnels
|
||||||
|
-- through one validated service-client server action that enforces
|
||||||
|
-- capacity, waitlisting and de-duplication (same pattern as
|
||||||
|
-- form_submissions / finishOnboarding). Nothing here is world-writable
|
||||||
|
-- and there is no anonymous path.
|
||||||
|
--
|
||||||
|
-- Extends seed_default_role_permissions() so new spaces seed both codes to
|
||||||
|
-- board, and backfills board -> both for existing spaces.
|
||||||
|
--
|
||||||
|
-- Idempotent: CREATE TABLE/INDEX IF NOT EXISTS, DROP POLICY IF EXISTS then
|
||||||
|
-- CREATE, CREATE OR REPLACE FUNCTION, ON CONFLICT DO NOTHING backfill.
|
||||||
|
-- =============================================================================
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
-- One active (non-cancelled) signup per member per session; a cancelled row
|
||||||
|
-- stays as history and a re-signup is allowed.
|
||||||
|
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: member signup/cancel goes through one validated
|
||||||
|
-- service-client server action (capacity/waitlist/dedupe enforced there).
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
|
||||||
|
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
|
||||||
|
AS $$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||||
|
SELECT NEW.id, d.subject, d.permission
|
||||||
|
FROM (VALUES
|
||||||
|
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
|
||||||
|
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
|
||||||
|
('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'),
|
||||||
|
('associate','ops.kb.read')
|
||||||
|
) AS d(subject, permission)
|
||||||
|
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -1828,6 +1828,7 @@ BEGIN
|
|||||||
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
|
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
|
||||||
('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
|
('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
|
||||||
('board','certifications.manage'),('board','certifications.grant'),
|
('board','certifications.manage'),('board','certifications.grant'),
|
||||||
|
('board','classes.manage'),('board','classes.instruct'),
|
||||||
('board','customize.manage'),('board','settings.manage'),
|
('board','customize.manage'),('board','settings.manage'),
|
||||||
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
|
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
|
||||||
('member','ops.kb.read'),('member','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)
|
INSERT INTO public.space_role_permissions (space_id, subject, permission)
|
||||||
SELECT id, 'board', 'certifications.grant' FROM public.spaces
|
SELECT id, 'board', 'certifications.grant' FROM public.spaces
|
||||||
ON CONFLICT (space_id, subject, permission) DO NOTHING;
|
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;
|
||||||
|
|||||||
@@ -118,6 +118,24 @@ export type Database = {
|
|||||||
Update: { id?: string; space_id?: string; member_id?: string; certification_id?: string; granted_by?: string | null; granted_at?: string; expires_at?: string | null; revoked_at?: string | null; revoked_by?: string | null; revoked_reason?: string | null; note?: string | null; created_at?: string }
|
Update: { id?: string; space_id?: string; member_id?: string; certification_id?: string; granted_by?: string | null; granted_at?: string; expires_at?: string | null; revoked_at?: string | null; revoked_by?: string | null; revoked_reason?: string | null; note?: string | null; created_at?: string }
|
||||||
Relationships: []
|
Relationships: []
|
||||||
}
|
}
|
||||||
|
classes: {
|
||||||
|
Row: { id: string; space_id: string; title: string; description: string | null; payment_link: string | null; capacity: number | null; is_active: boolean; grants_certification_id: string | null; created_by: string | null; created_at: string; updated_at: string }
|
||||||
|
Insert: { id?: string; space_id: string; title: string; description?: string | null; payment_link?: string | null; capacity?: number | null; is_active?: boolean; grants_certification_id?: string | null; created_by?: string | null; created_at?: string; updated_at?: string }
|
||||||
|
Update: { id?: string; space_id?: string; title?: string; description?: string | null; payment_link?: string | null; capacity?: number | null; is_active?: boolean; grants_certification_id?: string | null; created_by?: string | null; created_at?: string; updated_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
class_sessions: {
|
||||||
|
Row: { id: string; class_id: string; space_id: string; starts_at: string; ends_at: string | null; location: string | null; capacity: number | null; status: string; notes: string | null; created_at: string; updated_at: string }
|
||||||
|
Insert: { id?: string; class_id: string; space_id: string; starts_at: string; ends_at?: string | null; location?: string | null; capacity?: number | null; status?: string; notes?: string | null; created_at?: string; updated_at?: string }
|
||||||
|
Update: { id?: string; class_id?: string; space_id?: string; starts_at?: string; ends_at?: string | null; location?: string | null; capacity?: number | null; status?: string; notes?: string | null; created_at?: string; updated_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
|
class_signups: {
|
||||||
|
Row: { id: string; session_id: string; space_id: string; member_id: string; status: string; attended: boolean; signed_up_at: string; created_at: string }
|
||||||
|
Insert: { id?: string; session_id: string; space_id: string; member_id: string; status?: string; attended?: boolean; signed_up_at?: string; created_at?: string }
|
||||||
|
Update: { id?: string; session_id?: string; space_id?: string; member_id?: string; status?: string; attended?: boolean; signed_up_at?: string; created_at?: string }
|
||||||
|
Relationships: []
|
||||||
|
}
|
||||||
activity_log: {
|
activity_log: {
|
||||||
Row: {
|
Row: {
|
||||||
action: string
|
action: string
|
||||||
|
|||||||
Reference in New Issue
Block a user