feat(communication): add project subrooms and isolated guest access

This commit is contained in:
2026-09-30 14:23:03 +02:00
parent 21fe4723e3
commit ea641bb4d0
19 changed files with 1454 additions and 2 deletions

View File

@@ -0,0 +1,126 @@
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();