From 1ca70eb1b5ea4c212283a3bf62ddc2b417d0dfd5 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 22:11:17 -0700 Subject: [PATCH] feat(presence): migration 038 space_visits + spaces.host_requires_card space_visits: one row per visit (check-in/out, is_host, in/out notes); partial UNIQUE (space_id,member_id) WHERE checked_out_at IS NULL = one open visit per member. RLS additive/default-deny: SELECT = any space member (presence is social), NO client write policy (validated self-resolved service-client actions only; immutable history). No new permission code; org-wide attendance is all-members by product decision. spaces.host_requires_card (bool, default true) gates host check-in. Mirrored in schema.sql, types, DATABASE_SCHEMA, DB_SCHEMA_MAP. --- DB_SCHEMA_MAP.md | 6 ++-- docs/DATABASE_SCHEMA.md | 1 + scripts/038_presence_attendance.sql | 56 +++++++++++++++++++++++++++++ scripts/schema.sql | 41 +++++++++++++++++++++ types/database.ts | 9 +++++ 5 files changed, 111 insertions(+), 2 deletions(-) create mode 100644 scripts/038_presence_attendance.sql diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index 930ea5a..ca986b1 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -403,10 +403,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 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 | +| 038 | `space_visits` (presence/attendance) + `spaces.host_requires_card` bool default true. One open visit per member (partial unique); SELECT = any space member; service-client-only writes (self-resolved, immutable). No new permission code | --- -## Tables added by migrations 016-037 (quick map) +## Tables added by migrations 016-038 (quick map) | Table | Key columns | Purpose | |-------|-------------|---------| @@ -436,7 +437,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | `door_connections` | space_id, name, adapter (native_heatsync/generic_http), base_url, pinned_host (SSRF pin), auth_mode, secret_ref -> secrets, verbs jsonb, allow_member_self_entry, is_enabled | Door controller integration. CRUD = `door.manage`. Password lives in the encrypted secrets vault, never here | | `door_access_log` | space_id, connection_id, actor_member_id, target_member_id, action, success, detail (redacted), occurred_at | Append-only door audit. SELECT = `door.manage`/`door.operate`; NO write policy (validated service-client executor only; immutable) | | `door_card_slots` | space_id, connection_id -> door_connections, card_id -> member_cards, slot int, created_by | Per-connection integer-slot allocation map. UNIQUE(connection_id,slot) + UNIQUE(connection_id,card_id). SELECT = `door.manage`/`door.operate`; NO write policy (service-client executor only, in lockstep with the controller) | +| `space_visits` | space_id, member_id, checked_in_at, checked_out_at, is_host, check_in_note, check_out_note | Presence/attendance. Open (checked_out_at NULL) = present; partial UNIQUE one-open-per-member. SELECT = any space member; NO write policy (self-resolved service-client actions only; immutable). Org-wide attendance view = all members | -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`; `classes.required_form_id`; `spaces.host_requires_card`. New enum `comment_entity_type`. Helper functions: `user_effective_roles(uid,sid)`, `user_has_permission(uid,sid,perm)` (both SECURITY DEFINER, search_path=public), plus governance quorum/tally/SLA functions. diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index 1205918..f25ed88 100644 --- a/docs/DATABASE_SCHEMA.md +++ b/docs/DATABASE_SCHEMA.md @@ -717,6 +717,7 @@ migration is mirrored as a section in it. Tables/columns added: | 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`) | +| 038 | `space_visits` (presence/attendance: check-in/out, `is_host`, in/out notes; partial UNIQUE `(space_id,member_id) WHERE checked_out_at IS NULL` = one open visit). RLS additive/default-deny: SELECT = any space member (presence is social), NO client write policy (validated self-resolved service-client actions; immutable history). No new permission code; org-wide attendance view is all-members by product decision. Also adds `spaces.host_requires_card` (bool, default true) gating host check-in, enforced in the app | New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. diff --git a/scripts/038_presence_attendance.sql b/scripts/038_presence_attendance.sql new file mode 100644 index 0000000..107bced --- /dev/null +++ b/scripts/038_presence_attendance.sql @@ -0,0 +1,56 @@ +-- ============================================================================= +-- 038: Presence & attendance (check-in / check-out / hosting) +-- ============================================================================= +-- space_visits is one row per visit: a member checks in (optionally as a host, +-- optionally with a note), and later checks out (optionally with a note). An +-- open visit (checked_out_at IS NULL) means the member is currently present. +-- +-- * Partial UNIQUE (space_id, member_id) WHERE checked_out_at IS NULL: at +-- most one open visit per member per space. The action layer auto-closes a +-- stale open visit before starting a new one. +-- * Presence is inherently social, so any space member may SELECT. There is +-- deliberately NO client INSERT/UPDATE/DELETE policy: check-in/out funnel +-- through validated service-client actions that resolve the member +-- server-side (self-only) and enforce the one-open invariant and the +-- host-eligibility rule. History is not deletable (no DELETE path). +-- * No new permission code. The org-wide attendance view is visible to any +-- space member (product decision); managers are not special here. +-- +-- spaces.host_requires_card gates checking in *as a host*: when true (default) +-- a member must have an active member_card on file to mark themselves host; +-- a space may flip it off to let anyone self-mark host. Enforced in the app +-- (lib/presence-logic.ts + lib/actions/presence.ts); additive nullable-safe +-- column with a NOT NULL DEFAULT so existing spaces keep the safe behavior. +-- +-- Idempotent: ADD COLUMN / CREATE TABLE / CREATE INDEX IF NOT EXISTS, +-- DROP POLICY IF EXISTS then CREATE. +-- ============================================================================= + +ALTER TABLE public.spaces + ADD COLUMN IF NOT EXISTS host_requires_card boolean NOT NULL DEFAULT true; + +CREATE TABLE IF NOT EXISTS public.space_visits ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + 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, + checked_in_at timestamptz NOT NULL DEFAULT now(), + checked_out_at timestamptz, + is_host boolean NOT NULL DEFAULT false, + check_in_note text CHECK (check_in_note IS NULL OR char_length(check_in_note) <= 500), + check_out_note text CHECK (check_out_note IS NULL OR char_length(check_out_note) <= 500), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_space_visits_open + ON public.space_visits (space_id, member_id) WHERE checked_out_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_space_visits_present + ON public.space_visits (space_id) WHERE checked_out_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_space_visits_member + ON public.space_visits (space_id, member_id, checked_in_at DESC); + +ALTER TABLE public.space_visits ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS space_visits_select ON public.space_visits; +CREATE POLICY space_visits_select ON public.space_visits FOR SELECT + USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); +-- No INSERT/UPDATE/DELETE policy: the validated service-client actions are the +-- only writers; attendance history is immutable. diff --git a/scripts/schema.sql b/scripts/schema.sql index 48de6f7..08efa76 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -57,6 +57,7 @@ CREATE TABLE IF NOT EXISTS public.spaces ( invite_code text, require_approval boolean DEFAULT true, public_member_directory boolean DEFAULT false, + host_requires_card boolean NOT NULL DEFAULT true, webhook_secret text, settings jsonb DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now(), @@ -2498,3 +2499,43 @@ DROP TRIGGER IF EXISTS trg_door_card_slots_touch ON public.door_card_slots; CREATE TRIGGER trg_door_card_slots_touch BEFORE UPDATE ON public.door_card_slots FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at(); + + +-- ============================================================================= +-- 24. Presence & attendance (check-in / check-out / hosting) +-- (equivalent to scripts/038_presence_attendance.sql). +-- +-- space_visits: one row per visit; open (checked_out_at IS NULL) = present. +-- Partial UNIQUE (space_id, member_id) WHERE checked_out_at IS NULL keeps +-- at most one open visit per member (the action auto-closes a stale one +-- before a new check-in). SELECT = any space member (presence is social); +-- NO client write policy (validated service-client actions only, self- +-- resolved, immutable history). No new permission code; the org-wide +-- attendance view is visible to any space member by product decision. +-- spaces.host_requires_card (mirrored above) gates host check-in; enforced +-- in the app. +-- ============================================================================= + +CREATE TABLE IF NOT EXISTS public.space_visits ( + id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), + 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, + checked_in_at timestamptz NOT NULL DEFAULT now(), + checked_out_at timestamptz, + is_host boolean NOT NULL DEFAULT false, + check_in_note text CHECK (check_in_note IS NULL OR char_length(check_in_note) <= 500), + check_out_note text CHECK (check_out_note IS NULL OR char_length(check_out_note) <= 500), + created_at timestamptz NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_space_visits_open + ON public.space_visits (space_id, member_id) WHERE checked_out_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_space_visits_present + ON public.space_visits (space_id) WHERE checked_out_at IS NULL; +CREATE INDEX IF NOT EXISTS idx_space_visits_member + ON public.space_visits (space_id, member_id, checked_in_at DESC); + +ALTER TABLE public.space_visits ENABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS space_visits_select ON public.space_visits; +CREATE POLICY space_visits_select ON public.space_visits FOR SELECT + USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); diff --git a/types/database.ts b/types/database.ts index 4efe5d1..5ac5b79 100644 --- a/types/database.ts +++ b/types/database.ts @@ -172,6 +172,12 @@ export type Database = { Update: { id?: string; space_id?: string; connection_id?: string; card_id?: string; slot?: number; created_by?: string | null; created_at?: string; updated_at?: string } Relationships: [] } + space_visits: { + Row: { id: string; space_id: string; member_id: string; checked_in_at: string; checked_out_at: string | null; is_host: boolean; check_in_note: string | null; check_out_note: string | null; created_at: string } + Insert: { id?: string; space_id: string; member_id: string; checked_in_at?: string; checked_out_at?: string | null; is_host?: boolean; check_in_note?: string | null; check_out_note?: string | null; created_at?: string } + Update: { id?: string; space_id?: string; member_id?: string; checked_in_at?: string; checked_out_at?: string | null; is_host?: boolean; check_in_note?: string | null; check_out_note?: string | null; created_at?: string } + Relationships: [] + } activity_log: { Row: { action: string @@ -1421,6 +1427,7 @@ export type Database = { default_voting_window_hours: number description: string | null financial_visibility: Database["public"]["Enums"]["financial_visibility"] + host_requires_card: boolean id: string incident_sla_hours: number invite_code: string | null @@ -1446,6 +1453,7 @@ export type Database = { default_voting_window_hours?: number description?: string | null financial_visibility?: Database["public"]["Enums"]["financial_visibility"] + host_requires_card?: boolean id?: string incident_sla_hours?: number invite_code?: string | null @@ -1471,6 +1479,7 @@ export type Database = { default_voting_window_hours?: number description?: string | null financial_visibility?: Database["public"]["Enums"]["financial_visibility"] + host_requires_card?: boolean id?: string incident_sla_hours?: number invite_code?: string | null