-- Tier 2 Block C — multi-host / collaborators. -- -- An event can now have multiple users with one of three roles: -- owner — full access incl. delete + manage collaborators -- editor — guests/tokens/branding/messages/analytics -- viewer — read-only -- -- The existing events.host_id stays as a denormalised "primary owner" -- pointer (cheap join key, useful for the existing GET /events query). The -- source of truth for authz is event_collaborators — every handler resolves -- the caller's role through it. -- -- Schema #0005 in TIER2_PLAN.md; landing at 0008 because earlier slots are -- taken by Tier 1 work. DO $$ BEGIN CREATE TYPE collaborator_role AS ENUM ('owner', 'editor', 'viewer'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; CREATE TABLE IF NOT EXISTS event_collaborators ( event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, role collaborator_role NOT NULL, invited_by UUID REFERENCES users(id) ON DELETE SET NULL, invited_at TIMESTAMPTZ NOT NULL DEFAULT now(), accepted_at TIMESTAMPTZ, PRIMARY KEY (event_id, user_id) ); -- Fast lookup of "what events does this user have any role on" — used by -- the dashboard list and the role-resolution middleware. CREATE INDEX IF NOT EXISTS idx_collaborators_user ON event_collaborators(user_id); -- Pending invitations. The invitee may not have a GuestGuard account yet; -- the accept flow creates one (or links to an existing one by email) and -- promotes the row into event_collaborators. -- -- token_hash is sha256(raw); the raw token only ever lives in the email -- link, never in the DB. consumed_at is set on successful accept so re- -- using the link returns 410 Gone instead of silently re-adding the user. CREATE TABLE IF NOT EXISTS collaborator_invites ( token_hash TEXT PRIMARY KEY, event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, email TEXT NOT NULL, role collaborator_role NOT NULL, invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TIMESTAMPTZ NOT NULL, consumed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Find pending invites for an event quickly (the "Team" tab lists them). -- Partial index keeps the working set small even on a busy event. CREATE INDEX IF NOT EXISTS idx_collab_invites_event ON collaborator_invites(event_id) WHERE consumed_at IS NULL; -- Backfill: every existing event's host becomes its owner. Idempotent so -- re-running this migration after a partial failure (or against a freshly -- restored backup that already has rows) does the right thing. INSERT INTO event_collaborators (event_id, user_id, role, accepted_at, invited_at) SELECT id, host_id, 'owner', created_at, created_at FROM events ON CONFLICT (event_id, user_id) DO NOTHING;