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();

View File

@@ -505,6 +505,13 @@
"when": 1790640000001,
"tag": "0074_scanner_inbox_filing",
"breakpoints": true
},
{
"idx": 72,
"version": "7",
"when": 1790726400000,
"tag": "0075_project_collaboration",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,83 @@
import { sql } from "drizzle-orm"
import { pgTable, uuid, bigint, text, timestamp, boolean, integer, jsonb, index, uniqueIndex, check } from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { projects } from "./projects"
const time = (name: string) => timestamp(name, { withTimezone: true })
export const collaborationWorkspaces = pgTable("collaboration_workspaces", {
tenantId: bigint("tenant_id", { mode: "number" }).primaryKey().references(() => tenants.id, { onDelete: "cascade" }),
publicId: uuid("public_id").notNull().unique().defaultRandom(),
guestsEnabled: boolean("guests_enabled").notNull().default(true),
})
export const collaborationPrincipals = pgTable("collaboration_principals", {
id: uuid("id").primaryKey().defaultRandom(), kind: text("kind").notNull(),
tenantId: bigint("tenant_id", { mode: "number" }).references(() => tenants.id, { onDelete: "cascade" }),
userId: uuid("user_id").references(() => authUsers.id, { onDelete: "cascade" }),
remoteOrigin: text("remote_origin"), remoteActorId: uuid("remote_actor_id"),
displayName: text("display_name").notNull(), email: text("email"), disabled: boolean("disabled").notNull().default(false),
createdAt: time("created_at").notNull().defaultNow(),
}, t => ({
local: uniqueIndex("collaboration_local_actor").on(t.tenantId, t.userId).where(sql`${t.kind}='user'`),
guest: uniqueIndex("collaboration_guest_actor").on(t.tenantId, t.email).where(sql`${t.kind}='guest'`),
remote: uniqueIndex("collaboration_remote_actor").on(t.remoteOrigin, t.remoteActorId).where(sql`${t.kind}='remote'`),
kind: check("collaboration_principals_kind_check", sql`${t.kind} IN ('user','guest','remote')`),
identity: check("collaboration_principals_check", sql`(${t.kind}='user' AND ${t.tenantId} IS NOT NULL AND ${t.userId} IS NOT NULL AND ${t.remoteOrigin} IS NULL)
OR (${t.kind}='guest' AND ${t.tenantId} IS NOT NULL AND ${t.userId} IS NULL AND ${t.email} IS NOT NULL AND ${t.remoteOrigin} IS NULL)
OR (${t.kind}='remote' AND ${t.tenantId} IS NULL AND ${t.userId} IS NULL AND ${t.remoteOrigin} IS NOT NULL AND ${t.remoteActorId} IS NOT NULL)`),
}))
export const collaborationRooms = pgTable("collaboration_rooms", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }),
projectId: bigint("project_id", { mode: "number" }).references(() => projects.id, { onDelete: "cascade" }),
name: text("name").notNull(), topic: text("topic").notNull().default(""), sharedOverview: jsonb("shared_overview").notNull().default({}),
status: text("status").notNull().default("active"), sequence: bigint("sequence", { mode: "number" }).notNull().default(0),
createdBy: uuid("created_by").notNull().references(() => authUsers.id),
createdAt: time("created_at").notNull().defaultNow(), updatedAt: time("updated_at").notNull().defaultNow(),
}, t => ({ project: index("collaboration_project_rooms").on(t.tenantId, t.projectId),
name: check("collaboration_rooms_name_check", sql`length(${t.name}) BETWEEN 1 AND 160`),
status: check("collaboration_rooms_status_check", sql`${t.status} IN ('active','archived','blocked')`) }))
export const collaborationMembers = pgTable("collaboration_members", {
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id),
status: text("status").notNull().default("active"), role: text("role").notNull().default("participant"),
overviewAllowed: boolean("overview_allowed").notNull().default(false), historyFromSeq: bigint("history_from_seq", { mode: "number" }).notNull(),
lastReadSeq: bigint("last_read_seq", { mode: "number" }).notNull().default(0), revision: bigint("revision", { mode: "number" }).notNull().default(1),
expiresAt: time("expires_at"), joinedAt: time("joined_at").notNull().defaultNow(), revokedAt: time("revoked_at"),
}, t => ({ member: uniqueIndex("collaboration_members_room_id_principal_id_key").on(t.roomId, t.principalId),
status: check("collaboration_members_status_check", sql`${t.status} IN ('active','revoked')`),
role: check("collaboration_members_role_check", sql`${t.role} IN ('participant','reader')`) }))
export const collaborationMessages = pgTable("collaboration_messages", {
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
seq: bigint("seq", { mode: "number" }).notNull(), principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id),
clientId: uuid("client_id").notNull(), body: text("body").notNull(), createdAt: time("created_at").notNull().defaultNow(),
}, t => ({ seq: uniqueIndex("collaboration_messages_room_id_seq_key").on(t.roomId, t.seq),
command: uniqueIndex("collaboration_messages_room_id_principal_id_client_id_key").on(t.roomId, t.principalId, t.clientId),
body: check("collaboration_messages_body_check", sql`length(${t.body}) BETWEEN 1 AND 10000`) }))
export const collaborationInvitations = pgTable("collaboration_invitations", {
id: uuid("id").primaryKey().defaultRandom(), roomId: uuid("room_id").notNull().references(() => collaborationRooms.id, { onDelete: "cascade" }),
targetPrincipalId: uuid("target_principal_id").notNull().references(() => collaborationPrincipals.id), tokenHash: text("token_hash").notNull().unique(),
role: text("role").notNull(), overviewAllowed: boolean("overview_allowed").notNull().default(false),
invitedBy: uuid("invited_by").notNull().references(() => authUsers.id), status: text("status").notNull().default("pending"),
expiresAt: time("expires_at").notNull(), accessExpiresAt: time("access_expires_at"), createdAt: time("created_at").notNull().defaultNow(), acceptedAt: time("accepted_at"),
}, t => ({ recipient: index("collaboration_recipient_invitations").on(t.targetPrincipalId, t.status),
role: check("collaboration_invitations_role_check", sql`${t.role} IN ('participant','reader')`),
status: check("collaboration_invitations_status_check", sql`${t.status} IN ('pending','accepted','declined','revoked')`) }))
export const collaborationGuestChallenges = pgTable("collaboration_guest_challenges", {
id: uuid("id").primaryKey(), principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id, { onDelete: "cascade" }),
codeHash: text("code_hash").notNull(), attempts: integer("attempts").notNull().default(0), expiresAt: time("expires_at").notNull(), consumedAt: time("consumed_at"),
})
export const collaborationGuestSessions = pgTable("collaboration_guest_sessions", {
tokenHash: text("token_hash").primaryKey(), principalId: uuid("principal_id").notNull().references(() => collaborationPrincipals.id, { onDelete: "cascade" }),
expiresAt: time("expires_at").notNull(), createdAt: time("created_at").notNull().defaultNow(),
}, t => ({ expiry: index("collaboration_guest_session_expiry").on(t.expiresAt) }))
export const collaborationRateLimits = pgTable("collaboration_rate_limits", {
key: text("key").primaryKey(), hits: integer("hits").notNull(), resetsAt: time("resets_at").notNull(),
})
export const collaborationAudit = pgTable("collaboration_audit", {
id: bigint("id", { mode: "number" }).primaryKey().generatedByDefaultAsIdentity(),
tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }),
roomId: uuid("room_id").references(() => collaborationRooms.id, { onDelete: "set null" }),
actorId: uuid("actor_id").references(() => collaborationPrincipals.id), action: text("action").notNull(), subjectId: uuid("subject_id"),
createdAt: time("created_at").notNull().defaultNow(),
})

View File

@@ -101,3 +101,4 @@ export * from "./serialexecutions"
export * from "./public_links"
export * from "./wikipages"
export * from "./scanner_inbox"
export * from "./collaboration";