From 92c5737a25c7b9c690334ec678d0deebdf53692f Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 21:33:53 -0700 Subject: [PATCH] feat(classes): migration 037 classes.required_form_id (optional form gate) Nullable FK classes.required_form_id -> forms(id) ON DELETE SET NULL. Mirrored in schema.sql, types, DATABASE_SCHEMA, DB_SCHEMA_MAP. Additive nullable column on the already-policied classes table (no RLS change); the signup gate is enforced in the app, mirroring the equipment cert-gate. --- DB_SCHEMA_MAP.md | 5 +++-- docs/DATABASE_SCHEMA.md | 1 + scripts/037_class_required_form.sql | 25 +++++++++++++++++++++++++ scripts/schema.sql | 1 + types/database.ts | 6 +++--- 5 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 scripts/037_class_required_form.sql diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index 00d8903..930ea5a 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -402,10 +402,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 034 | `member_cards` + `door.manage` / `door.operate` permissions. Card UID is a credential: door.manage-only RLS, no member SELECT; masked (count+last4) self-view via server action. Door epic phase 1, no controller calls | | 035 | `door_connections`, `door_access_log` (Door epic P2). Password via secrets vault (secret_ref), `pinned_host` SSRF pin, hardened executor. Connections CRUD = door.manage; log SELECT = door.manage/operate, service-client-only writes | | 036 | `door_card_slots` (Door epic P3). Per-connection integer-slot allocation map (HeatSync keys cards by slot 0-200). UNIQUE(connection_id,slot) + UNIQUE(connection_id,card_id); lowest-free policy in pure logic. SELECT = door.manage/operate, service-client-only writes | +| 037 | `classes.required_form_id` nullable FK -> forms(id) ON DELETE SET NULL. Optional per-class form gate (waiver-on-file): signup requires a completed form_submissions row; classes.manage override + on-behalf. App-enforced; no RLS change | --- -## Tables added by migrations 016-036 (quick map) +## Tables added by migrations 016-037 (quick map) | Table | Key columns | Purpose | |-------|-------------|---------| @@ -426,7 +427,7 @@ 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) | | `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) | -| `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` | +| `classes` | space_id, title, description, payment_link (http(s) only), capacity, is_active, grants_certification_id -> certifications, required_form_id -> forms | Class offering. SELECT = `classes.manage` (all) or member (`is_active`); writes = `classes.manage`. Optional `required_form_id` hard-gates signup (waiver-on-file, app-enforced) | | `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) | | `equipment` | space_id, name, description, location, status (available/maintenance/retired), required_certification_id -> certifications, asset_tag, is_active | Tool/equipment registry. SELECT = `equipment.manage` (all) or member (`is_active`); writes = `equipment.manage` | diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 74a4753..1205918 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -716,6 +716,7 @@ migration is mirrored as a section in it. Tables/columns added: | 034 | `member_cards` (RFID/NFC card UID -> member; the UID is a credential -- `door.manage`-only RLS, no member SELECT policy; masked self-view via a server action). New `door.manage` + `door.operate` permissions (group Access; the whole Door epic uses them) seeded to board + backfilled. No controller calls in this phase, no anonymous path | | 035 | `door_connections` (per-space controller integration; shared password NOT stored -- `secret_ref` -> AES-256-GCM `secrets` vault, decrypted server-side only; `pinned_host` SSRF pin; adapter native_heatsync|generic_http; `allow_member_self_entry` opt-in) + `door_access_log` (append-only, secrets redacted). RLS additive + default-deny: door_connections CRUD = `door.manage`; door_access_log SELECT = `door.manage`/`door.operate`, NO write policy (service-client executor only; immutable). No new permission codes (door.* from 034). No anonymous path | | 036 | `door_card_slots` (per-connection integer-slot allocation map for controllers that key cards by slot, e.g. HeatSync 0-200). `UNIQUE (connection_id, slot)` lets the DB arbitrate concurrent grants; `UNIQUE (connection_id, card_id)` makes re-grant idempotent. Allocation policy (lowest-free, range bounds) is pure unit-tested logic, not SQL, so it stays adapter-generic. RLS additive + default-deny: SELECT = `door.manage`/`door.operate`, NO write policy (validated service-client executor only, in lockstep with the controller). No new permission codes. No anonymous path | +| 037 | `classes.required_form_id` nullable FK -> `forms(id)` ON DELETE SET NULL. A class can optionally require a form (waiver/intake); signup is hard-gated on the member having a completed `form_submissions` row for it (waiver-on-file), with a `classes.manage` override + sign-up-on-behalf, mirroring the equipment required-cert gate. Gate enforced in the app (pure logic + service-client boolean check); no RLS change (additive nullable column on the already-policied `classes`) | New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. diff --git a/scripts/037_class_required_form.sql b/scripts/037_class_required_form.sql new file mode 100644 index 0000000..2d31698 --- /dev/null +++ b/scripts/037_class_required_form.sql @@ -0,0 +1,25 @@ +-- ============================================================================= +-- 037: A class can optionally require a form +-- ============================================================================= +-- A class may point at one form in the same space (a waiver or intake form). +-- When set, signing up for any session of that class requires the member to +-- have a completed submission of that form on file (the "waiver on file" +-- model). The gate is enforced in the application +-- (lib/classes-logic.ts + lib/actions/classes.ts), mirroring the existing +-- equipment required-certification gate: a classes.manage holder can override +-- and sign a member up on their behalf. The submission check reads +-- form_submissions via the service client (boolean only) since a class +-- manager need not hold forms.manage. +-- +-- ON DELETE SET NULL: deleting the form just drops the requirement; existing +-- signups are unaffected. +-- +-- No RLS change: classes already has additive default-deny policies and this +-- is an additional nullable column on it. +-- +-- Idempotent: ADD COLUMN IF NOT EXISTS. +-- ============================================================================= + +ALTER TABLE public.classes + ADD COLUMN IF NOT EXISTS required_form_id uuid + REFERENCES public.forms(id) ON DELETE SET NULL; diff --git a/scripts/schema.sql b/scripts/schema.sql index 38e5fc3..48de6f7 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -2115,6 +2115,7 @@ CREATE TABLE IF NOT EXISTS public.classes ( 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, + required_form_id uuid REFERENCES public.forms(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() diff --git a/types/database.ts b/types/database.ts index be70d69..4efe5d1 100644 --- a/types/database.ts +++ b/types/database.ts @@ -119,9 +119,9 @@ export type Database = { 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 } + 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; required_form_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; required_form_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; required_form_id?: string | null; created_by?: string | null; created_at?: string; updated_at?: string } Relationships: [] } class_sessions: {