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.
This commit is contained in:
@@ -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.
|
||||
@@ -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())));
|
||||
|
||||
Reference in New Issue
Block a user