feat(communication): add project subrooms and isolated guest access
This commit is contained in:
126
backend/db/migrations/0075_project_collaboration.sql
Normal file
126
backend/db/migrations/0075_project_collaboration.sql
Normal 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();
|
||||
Reference in New Issue
Block a user