CREATE TABLE collaboration_workspaces ( tenant_id bigint PRIMARY KEY REFERENCES tenants(id) ON DELETE CASCADE, public_id uuid NOT NULL UNIQUE DEFAULT gen_random_uuid(), guests_enabled boolean NOT NULL DEFAULT true ); CREATE TABLE collaboration_principals ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), kind text NOT NULL CHECK (kind IN ('user','guest','remote')), tenant_id bigint REFERENCES tenants(id) ON DELETE CASCADE, user_id uuid REFERENCES auth_users(id) ON DELETE CASCADE, remote_origin text, remote_actor_id uuid, display_name text NOT NULL, email text, disabled boolean NOT NULL DEFAULT false, created_at timestamptz NOT NULL DEFAULT now(), CHECK ((kind='user' AND tenant_id IS NOT NULL AND user_id IS NOT NULL AND remote_origin IS NULL) OR (kind='guest' AND tenant_id IS NOT NULL AND user_id IS NULL AND email IS NOT NULL AND remote_origin IS NULL) OR (kind='remote' AND tenant_id IS NULL AND user_id IS NULL AND remote_origin IS NOT NULL AND remote_actor_id IS NOT NULL)) ); CREATE UNIQUE INDEX collaboration_local_actor ON collaboration_principals(tenant_id,user_id) WHERE kind='user'; CREATE UNIQUE INDEX collaboration_guest_actor ON collaboration_principals(tenant_id,email) WHERE kind='guest'; CREATE UNIQUE INDEX collaboration_remote_actor ON collaboration_principals(remote_origin,remote_actor_id) WHERE kind='remote'; CREATE TABLE collaboration_rooms ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, project_id bigint REFERENCES projects(id) ON DELETE CASCADE, name text NOT NULL CHECK (length(name) BETWEEN 1 AND 160), topic text NOT NULL DEFAULT '', shared_overview jsonb NOT NULL DEFAULT '{}', status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','archived','blocked')), sequence bigint NOT NULL DEFAULT 0, created_by uuid NOT NULL REFERENCES auth_users(id), created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX collaboration_project_rooms ON collaboration_rooms(tenant_id,project_id); CREATE TABLE collaboration_members ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), room_id uuid NOT NULL REFERENCES collaboration_rooms(id) ON DELETE CASCADE, principal_id uuid NOT NULL REFERENCES collaboration_principals(id), status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','revoked')), role text NOT NULL DEFAULT 'participant' CHECK (role IN ('participant','reader')), overview_allowed boolean NOT NULL DEFAULT false, history_from_seq bigint NOT NULL, last_read_seq bigint NOT NULL DEFAULT 0, revision bigint NOT NULL DEFAULT 1, expires_at timestamptz, joined_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz, UNIQUE(room_id,principal_id) ); CREATE TABLE collaboration_messages ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), room_id uuid NOT NULL REFERENCES collaboration_rooms(id) ON DELETE CASCADE, seq bigint NOT NULL, principal_id uuid NOT NULL REFERENCES collaboration_principals(id), client_id uuid NOT NULL, body text NOT NULL CHECK (length(body) BETWEEN 1 AND 10000), created_at timestamptz NOT NULL DEFAULT now(), UNIQUE(room_id,seq), UNIQUE(room_id,principal_id,client_id) ); CREATE TABLE collaboration_invitations ( id uuid PRIMARY KEY DEFAULT gen_random_uuid(), room_id uuid NOT NULL REFERENCES collaboration_rooms(id) ON DELETE CASCADE, target_principal_id uuid NOT NULL REFERENCES collaboration_principals(id), token_hash text NOT NULL UNIQUE, role text NOT NULL CHECK (role IN ('participant','reader')), overview_allowed boolean NOT NULL DEFAULT false, invited_by uuid NOT NULL REFERENCES auth_users(id), status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','accepted','declined','revoked')), expires_at timestamptz NOT NULL, access_expires_at timestamptz, created_at timestamptz NOT NULL DEFAULT now(), accepted_at timestamptz ); CREATE INDEX collaboration_recipient_invitations ON collaboration_invitations(target_principal_id,status); CREATE TABLE collaboration_guest_challenges ( id uuid PRIMARY KEY, principal_id uuid NOT NULL REFERENCES collaboration_principals(id) ON DELETE CASCADE, code_hash text NOT NULL, attempts integer NOT NULL DEFAULT 0, expires_at timestamptz NOT NULL, consumed_at timestamptz ); CREATE TABLE collaboration_guest_sessions ( token_hash text PRIMARY KEY, principal_id uuid NOT NULL REFERENCES collaboration_principals(id) ON DELETE CASCADE, expires_at timestamptz NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX collaboration_guest_session_expiry ON collaboration_guest_sessions(expires_at); CREATE TABLE collaboration_rate_limits ( key text PRIMARY KEY, hits integer NOT NULL, resets_at timestamptz NOT NULL ); CREATE TABLE collaboration_audit ( id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, tenant_id bigint NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, room_id uuid REFERENCES collaboration_rooms(id) ON DELETE SET NULL, actor_id uuid REFERENCES collaboration_principals(id), action text NOT NULL, subject_id uuid, created_at timestamptz NOT NULL DEFAULT now() ); -- This must also hold for direct SQL writes, not just the HTTP validation. CREATE FUNCTION collaboration_check_project_tenant() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.project_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM projects WHERE id=NEW.project_id AND tenant=NEW.tenant_id ) THEN RAISE EXCEPTION 'Project belongs to another tenant' USING ERRCODE='23514'; END IF; IF TG_OP='UPDATE' AND (NEW.project_id IS DISTINCT FROM OLD.project_id OR NEW.tenant_id<>OLD.tenant_id) THEN RAISE EXCEPTION 'Collaboration ownership is immutable' USING ERRCODE='23514'; END IF; RETURN NEW; END $$; CREATE TRIGGER collaboration_project_tenant BEFORE INSERT OR UPDATE ON collaboration_rooms FOR EACH ROW EXECUTE FUNCTION collaboration_check_project_tenant();