diff --git a/backend/db/migrations/0075_project_collaboration.sql b/backend/db/migrations/0075_project_collaboration.sql new file mode 100644 index 00000000..8fecadde --- /dev/null +++ b/backend/db/migrations/0075_project_collaboration.sql @@ -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(); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index d479c6cd..ff645c3d 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -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 } ] } diff --git a/backend/db/schema/collaboration.ts b/backend/db/schema/collaboration.ts new file mode 100644 index 00000000..1ffcc9ca --- /dev/null +++ b/backend/db/schema/collaboration.ts @@ -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(), +}) diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index e7acf454..0a2c10d1 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -101,3 +101,4 @@ export * from "./serialexecutions" export * from "./public_links" export * from "./wikipages" export * from "./scanner_inbox" +export * from "./collaboration"; diff --git a/backend/src/index.ts b/backend/src/index.ts index 342feebe..78cd0e08 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -39,6 +39,8 @@ import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway"; import documentImportRoutes from "./routes/documentImports"; import scannerInboxGateway from "./routes/scannerInboxGateway"; import documentInboxRoutes from "./routes/documentInbox"; +import { collaborationRoutes, collaborationGuestRoutes } from "./routes/collaboration"; +import { collaborationRuntime } from "./modules/collaboration/runtime"; //Public Links import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated"; @@ -89,6 +91,7 @@ async function main() { await app.register(tenantPlugin); await app.register(dayjsPlugin); await app.register(dbPlugin); + const collaboration = collaborationRuntime(app); await app.register(servicesPlugin); await runBootstrap(app); startCentralServicesHeartbeat(app); @@ -112,6 +115,7 @@ async function main() { // Öffentliche Routes await app.register(authRoutes); await app.register(healthRoutes); + await app.register(collaborationGuestRoutes, collaboration); await app.register(helpdeskInboundRoutes); @@ -168,6 +172,7 @@ async function main() { await subApp.register(portalContractRoutes); await subApp.register(mcpRoutes); await subApp.register(communicationRoutes); + await subApp.register(collaborationRoutes, collaboration); await subApp.register(telephonyRoutes); await subApp.register(instanceAgentRoutes); await subApp.register(documentImportRoutes); diff --git a/backend/src/modules/collaboration/runtime.ts b/backend/src/modules/collaboration/runtime.ts new file mode 100644 index 00000000..8f0d0fe7 --- /dev/null +++ b/backend/src/modules/collaboration/runtime.ts @@ -0,0 +1,15 @@ +import type { FastifyInstance } from "fastify" +import { secrets } from "../../utils/secrets" +import { sendMail } from "../../utils/mailer" +import { CollaborationService } from "./service" + +export function collaborationRuntime(server: FastifyInstance) { + const apiUrl = String(process.env.FEDEO_FEDERATION_URL || secrets.API_BASE_URL || "").replace(/\/$/, "") + const publicUrl = String(process.env.FEDEO_PUBLIC_URL || (apiUrl ? new URL(apiUrl).origin : "")).replace(/\/$/, "") + const service = new CollaborationService(server.db, { + publicUrl, + guestPepper: () => secrets.ENCRYPTION_KEY || "", + sendMail, + }) + return { service, apiUrl } +} diff --git a/backend/src/modules/collaboration/service.ts b/backend/src/modules/collaboration/service.ts new file mode 100644 index 00000000..f934de14 --- /dev/null +++ b/backend/src/modules/collaboration/service.ts @@ -0,0 +1,458 @@ +import { randomBytes, randomInt, randomUUID, createHmac, timingSafeEqual } from "node:crypto" +import { sql } from "drizzle-orm" +import type { NodePgDatabase } from "drizzle-orm/node-postgres" +import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types" + +type Db = Pick, "execute" | "transaction"> +export const rows = async (db: Pick, query: any): Promise => (await db.execute(query)).rows +export type CollaborationOptions = { + publicUrl: string; + guestPepper: () => string; + sendMail: (to: string, subject: string, html: string) => Promise<{ success: boolean }>; + changed?: (roomId: string, db: any) => Promise; +} + +/** Owner-side authority. All membership changes and sends serialize on the room row. */ +export class CollaborationService { + constructor(readonly db: Db, readonly options: CollaborationOptions) {} + + async currentUser(userId: string, tenantId: number, db: Db = this.db) { + uuid(userId) + if (!Number.isSafeInteger(tenantId) || tenantId < 1) fail("Kein aktiver Mandant", 403) + const [user] = await rows(db, sql` + SELECT u.id, u.email, COALESCE(NULLIF(p.full_name,''),u.email) AS name, + (u.is_admin OR EXISTS (SELECT 1 FROM auth_user_roles ur + JOIN auth_roles r ON r.id=ur.role_id AND (r.tenant_id IS NULL OR r.tenant_id=${tenantId}) + JOIN auth_role_permissions rp ON rp.role_id=r.id + WHERE ur.user_id=u.id AND ur.tenant_id=${tenantId} AND rp.permission='organisation.projects.write')) AS manage + FROM auth_users u JOIN auth_tenant_users tu ON tu.user_id=u.id AND tu.tenant_id=${tenantId} + JOIN tenants t ON t.id=tu.tenant_id + LEFT JOIN auth_profiles p ON p.user_id=u.id AND p.tenant_id=tu.tenant_id + WHERE u.id=${userId} AND (p.id IS NULL OR p.active) AND t.locked IS DISTINCT FROM 'maintenance_tenant' LIMIT 1`) + if (!user) fail("Keine aktive Mandantenmitgliedschaft", 403) + return user + } + + async userContext(userId: string, tenantId: number): Promise { + const user = await this.currentUser(userId, tenantId) + const [principal] = await rows(this.db, sql` + INSERT INTO collaboration_principals(kind,tenant_id,user_id,display_name,email) + VALUES ('user',${tenantId},${userId},${user.name},${user.email.toLowerCase()}) + ON CONFLICT (tenant_id,user_id) WHERE kind='user' DO UPDATE SET display_name=excluded.display_name,email=excluded.email + RETURNING *`) + if (principal.disabled) fail("Zusammenarbeit ist für diesen Benutzer gesperrt", 403) + return { userId, tenantId, principal, manage: Boolean(user.manage) } + } + + async workspace(tenantId: number, db: Db = this.db) { + const [workspace] = await rows(db, sql`INSERT INTO collaboration_workspaces(tenant_id) VALUES (${tenantId}) + ON CONFLICT (tenant_id) DO UPDATE SET tenant_id=excluded.tenant_id RETURNING *`) + return workspace + } + + async projects(ctx: UserContext) { + if (!ctx.manage) return [] + return rows(this.db, sql`SELECT id,name FROM projects WHERE tenant=${ctx.tenantId} AND NOT archived ORDER BY name`) + } + + async localContacts(ctx: UserContext) { + if (!ctx.manage) fail("Keine Berechtigung zur Teilnehmerverwaltung", 403) + return rows(this.db, sql`SELECT u.id, COALESCE(NULLIF(p.full_name,''),u.email) AS name + FROM auth_tenant_users tu JOIN auth_users u ON u.id=tu.user_id + LEFT JOIN auth_profiles p ON p.user_id=u.id AND p.tenant_id=tu.tenant_id + WHERE tu.tenant_id=${ctx.tenantId} AND (p.id IS NULL OR p.active) ORDER BY name`) + } + + async assertPrincipal(principal: Principal, db: Db = this.db) { + const [current] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${principal.id} AND NOT disabled`) + if (!current) fail("Teilnehmer ist nicht verfügbar", 403) + if (current.kind === "user") await this.currentUser(current.user_id, Number(current.tenant_id), db) + if (current.kind === "guest") { + const [allowed] = await rows(db, sql`SELECT 1 FROM collaboration_workspaces w JOIN tenants t ON t.id=w.tenant_id + WHERE w.tenant_id=${current.tenant_id} AND w.guests_enabled AND t.locked IS DISTINCT FROM 'maintenance_tenant'`) + if (!allowed) fail("Gastzugang ist gesperrt", 403) + } + return current as Principal + } + + async roomRow(db: Db, roomId: string, lock = false) { + const [room] = await rows(db, sql`SELECT r.*, p.archived AS project_archived, t.locked AS tenant_locked + FROM collaboration_rooms r JOIN tenants t ON t.id=r.tenant_id + LEFT JOIN projects p ON p.id=r.project_id AND p.tenant=r.tenant_id + WHERE r.id=${uuid(roomId)} ${lock ? sql`FOR UPDATE OF r` : sql``}`) + if (!room) fail("Unterraum nicht gefunden", 404) + return room + } + + assertOpen(room: any, write = false) { + if (room.status === "blocked" || room.tenant_locked === "maintenance_tenant") fail("Unterraum ist gesperrt", 403) + if (write && (room.status !== "active" || room.project_archived)) fail("Unterraum ist archiviert", 409) + } + + async membership(db: Db, room: any, principal: Principal, write = false) { + this.assertOpen(room, write) + await this.assertPrincipal(principal, db) + const [member] = await rows(db, sql`SELECT * FROM collaboration_members + WHERE room_id=${room.id} AND principal_id=${principal.id} AND status='active' + AND (expires_at IS NULL OR expires_at>now())`) + if (!member) fail("Unterraum nicht gefunden oder kein Zugriff", 404) + if (write && member.role !== "participant") fail("Dieser Unterraum ist für dich nur lesbar", 403) + return member + } + + async manager(db: Db, ctx: UserContext, room: any) { + const user = await this.currentUser(ctx.userId, ctx.tenantId, db) + if (Number(room.tenant_id) !== ctx.tenantId || !user.manage) fail("Keine Berechtigung zur Unterraumverwaltung", 403) + } + + async audit(db: Db, room: any, actor: Principal, action: string, subjectId?: string) { + await db.execute(sql`INSERT INTO collaboration_audit(tenant_id,room_id,actor_id,action,subject_id) + VALUES (${room.tenant_id},${room.id},${actor.id},${action},${subjectId || null})`) + } + + async createRoom(ctx: UserContext, input: any) { + const name = text(input.name), topic = text(input.topic, 1000, true) + const projectId = input.projectId == null ? null : Number(input.projectId) + if (projectId !== null && (!Number.isSafeInteger(projectId) || projectId < 1)) fail("Ungültiges Projekt") + if (!ctx.manage) fail("Keine Berechtigung zum Anlegen von Unterräumen", 403) + return this.db.transaction(async (db) => { + const user = await this.currentUser(ctx.userId, ctx.tenantId, db) + if (!user.manage) fail("Keine Berechtigung", 403) + if (projectId !== null) { + const [project] = await rows(db, sql`SELECT id FROM projects WHERE id=${projectId} AND tenant=${ctx.tenantId} AND NOT archived FOR SHARE`) + if (!project) fail("Projekt nicht verfügbar", 404) + } + await this.workspace(ctx.tenantId, db) + const [room] = await rows(db, sql`INSERT INTO collaboration_rooms(tenant_id,project_id,name,topic,created_by) + VALUES (${ctx.tenantId},${projectId},${name},${topic},${ctx.userId}) RETURNING *`) + await db.execute(sql`INSERT INTO collaboration_members(room_id,principal_id,history_from_seq,overview_allowed) + VALUES (${room.id},${ctx.principal.id},1,true)`) + await this.audit(db, room, ctx.principal, "room.created") + return { id: room.id, name: room.name } + }) + } + + async listRooms(principal: Principal) { + await this.assertPrincipal(principal) + return rows(this.db, sql`SELECT r.id,r.name,r.topic,r.status,r.project_id IS NOT NULL AS "isProjectRoom", + (r.status='active' AND NOT coalesce(p.archived,false) AND m.role='participant') AS "canSend", + t.name AS "ownerName", m.overview_allowed AS "overviewAllowed", + (SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id + AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread + FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id + LEFT JOIN projects p ON p.id=r.project_id + WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now()) + AND r.status<>'blocked' AND t.locked IS DISTINCT FROM 'maintenance_tenant' + ORDER BY r.updated_at DESC`) + } + + async managedRooms(ctx: UserContext, projectId?: number) { + if (!ctx.manage) return [] + return rows(this.db, sql`SELECT r.id,r.name,r.topic,r.status,r.project_id AS "projectId",r.shared_overview AS "overview", + EXISTS(SELECT 1 FROM collaboration_members m WHERE m.room_id=r.id AND m.principal_id=${ctx.principal.id} + AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())) AS "isMember" + FROM collaboration_rooms r WHERE r.tenant_id=${ctx.tenantId} + ${projectId ? sql`AND r.project_id=${projectId}` : sql``} ORDER BY r.created_at DESC`) + } + + async room(principal: Principal, roomId: string) { + const room = await this.roomRow(this.db, roomId) + const member = await this.membership(this.db, room, principal) + return { + id: room.id, name: room.name, topic: room.topic, + status: room.project_archived ? "archived" : room.status, + canSend: room.status === "active" && !room.project_archived && member.role === "participant", + overview: member.overview_allowed ? room.shared_overview : null, + historyFromSeq: Number(member.history_from_seq), lastReadSeq: Number(member.last_read_seq), revision: Number(member.revision), + } + } + + async messages(principal: Principal, roomId: string, query: any = {}) { + const room = await this.roomRow(this.db, roomId) + const member = await this.membership(this.db, room, principal) + const after = sequence(query.after), before = sequence(query.before) + const result = await rows(this.db, sql`SELECT m.id,m.client_id AS "clientId",m.seq::float8 AS seq,m.body,m.created_at AS "createdAt", + p.display_name AS "senderName",p.kind AS "senderKind", m.principal_id=${principal.id} AS own + FROM collaboration_messages m JOIN collaboration_principals p ON p.id=m.principal_id + WHERE m.room_id=${room.id} AND m.seq>=${member.history_from_seq} + ${after ? sql`AND m.seq>${after}` : sql``} ${before ? sql`AND m.seq<${before}` : sql``} + ORDER BY m.seq ${after ? sql`ASC` : sql`DESC`} LIMIT 50`) + return { messages: after ? result : result.reverse(), hasMore: result.length === 50 } + } + + async send(principal: Principal, roomId: string, input: any) { + const body = text(input.text, 10000), clientId = uuid(input.clientId) + return this.db.transaction(async (db) => { + const room = await this.roomRow(db, roomId, true) + await this.membership(db, room, principal, true) + const [existing] = await rows(db, sql`SELECT id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt" FROM collaboration_messages + WHERE room_id=${room.id} AND principal_id=${principal.id} AND client_id=${clientId}`) + if (existing) { + if (existing.body !== body) fail("Diese Auftrags-ID wurde bereits für eine andere Nachricht verwendet", 409) + return { ...existing, own: true, senderName: principal.display_name, senderKind: principal.kind } + } + const [next] = await rows(db, sql`UPDATE collaboration_rooms SET sequence=sequence+1,updated_at=now() WHERE id=${room.id} RETURNING sequence`) + const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body) + VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body}) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`) + await this.options.changed?.(room.id, db) + return { ...message, own: true, senderName: principal.display_name, senderKind: principal.kind } + }) + } + + async markRead(principal: Principal, roomId: string, value: unknown) { + const requested = sequence(value) + return this.db.transaction(async (db) => { + const room = await this.roomRow(db, roomId, true) + await this.membership(db, room, principal) + await db.execute(sql`UPDATE collaboration_members SET last_read_seq=GREATEST(last_read_seq,${Math.min(requested, Number(room.sequence))}) + WHERE room_id=${room.id} AND principal_id=${principal.id}`) + return { read: true } + }) + } + + async members(principal: Principal, roomId: string) { + const room = await this.roomRow(this.db, roomId) + await this.membership(this.db, room, principal) + return rows(this.db, sql`SELECT p.id,p.display_name AS name,p.kind,m.role,p.id=${principal.id} AS own + FROM collaboration_members m JOIN collaboration_principals p ON p.id=m.principal_id + WHERE m.room_id=${room.id} AND m.status='active' AND NOT p.disabled AND (m.expires_at IS NULL OR m.expires_at>now()) + ORDER BY p.display_name`) + } + + async updateRoom(ctx: UserContext, roomId: string, input: any) { + if (Object.keys(input).some((key) => !["name", "topic", "status", "overview"].includes(key))) fail("Unbekanntes Unterraumfeld") + return this.db.transaction(async (db) => { + const room = await this.roomRow(db, roomId, true) + await this.manager(db, ctx, room) + const status = input.status ?? room.status + if (!["active", "archived", "blocked"].includes(status)) fail("Ungültiger Status") + await db.execute(sql`UPDATE collaboration_rooms SET name=${input.name === undefined ? room.name : text(input.name)}, + topic=${input.topic === undefined ? room.topic : text(input.topic, 1000, true)},status=${status}, + shared_overview=${JSON.stringify(input.overview === undefined ? room.shared_overview : overview(input.overview))}::jsonb,updated_at=now() + WHERE id=${room.id}`) + if (status !== "active") await db.execute(sql`UPDATE collaboration_invitations SET status='revoked' WHERE room_id=${room.id} AND status='pending'`) + await this.audit(db, room, ctx.principal, "room.updated") + await this.options.changed?.(room.id, db) + return { updated: true } + }) + } + + async guest(ctx: UserContext, input: any): Promise { + if (!ctx.manage) fail("Keine Berechtigung", 403) + const workspace = await this.workspace(ctx.tenantId) + if (!workspace.guests_enabled) fail("Gastzugang ist deaktiviert", 403) + const address = email(input.email), name = text(input.name) + const [principal] = await rows(this.db, sql`INSERT INTO collaboration_principals(kind,tenant_id,email,display_name) + VALUES ('guest',${ctx.tenantId},${address},${name}) ON CONFLICT (tenant_id,email) WHERE kind='guest' + DO UPDATE SET email=excluded.email RETURNING *`) + if (principal.disabled) fail("Gastzugang ist gesperrt", 403) + return principal + } + + async invite(ctx: UserContext, roomId: string, target: Principal, input: any) { + const role = input.role || "participant" + if (!["participant", "reader"].includes(role)) fail("Ungültige Rolle") + if (input.overviewAllowed !== undefined && typeof input.overviewAllowed !== "boolean") fail("Ungültige Freigabe") + const accessExpires = futureDate(input.expiresAt) || (target.kind === "guest" ? new Date(Date.now() + 30 * 86400000) : null) + const token = randomBytes(32).toString("base64url") + const result = await this.db.transaction(async (db) => { + const room = await this.roomRow(db, roomId, true) + await this.manager(db, ctx, room) + this.assertOpen(room, true) + await this.assertPrincipal(target, db) + if (target.kind === "guest" && (Number(target.tenant_id) !== ctx.tenantId || !room.project_id)) { + fail("Gäste können nur in Projekt-Unterräume eingeladen werden") + } + const [active] = await rows(db, sql`SELECT id FROM collaboration_members WHERE room_id=${room.id} AND principal_id=${target.id} + AND status='active' AND (expires_at IS NULL OR expires_at>now())`) + if (active) fail("Diese Person nimmt bereits teil", 409) + await db.execute(sql`UPDATE collaboration_invitations SET status='revoked' WHERE room_id=${room.id} AND target_principal_id=${target.id} AND status='pending'`) + const [invite] = await rows(db, sql`INSERT INTO collaboration_invitations(room_id,target_principal_id,token_hash,role,overview_allowed,invited_by,expires_at,access_expires_at) + VALUES (${room.id},${target.id},${hash(token)},${role},${input.overviewAllowed === true},${ctx.userId}, + LEAST(now()+interval '7 days',${accessExpires}::timestamptz),${accessExpires}) RETURNING id,expires_at AS "expiresAt"`) + await this.audit(db, room, ctx.principal, "invitation.created", invite.id) + return invite + }) + return { ...result, token } + } + + async invitations(principal: Principal) { + await this.assertPrincipal(principal) + return rows(this.db, sql`SELECT i.id,r.name,r.topic,i.role,i.overview_allowed AS "overviewAllowed", i.expires_at AS "expiresAt",t.name AS "ownerName" + FROM collaboration_invitations i JOIN collaboration_rooms r ON r.id=i.room_id JOIN tenants t ON t.id=r.tenant_id + LEFT JOIN projects p ON p.id=r.project_id + WHERE i.target_principal_id=${principal.id} AND i.status='pending' AND i.expires_at>now() + AND r.status='active' AND NOT coalesce(p.archived,false) AND t.locked IS DISTINCT FROM 'maintenance_tenant' ORDER BY i.created_at DESC`) + } + + async accept(principal: Principal, invitationId: string, token?: string) { + const [hint] = await rows(this.db, sql`SELECT room_id FROM collaboration_invitations WHERE id=${uuid(invitationId)}`) + if (!hint) fail("Einladung nicht verfügbar", 404) + return this.db.transaction(async (db) => { + const room = await this.roomRow(db, hint.room_id, true) + this.assertOpen(room, true) + await this.assertPrincipal(principal, db) + const [invite] = await rows(db, sql`SELECT * FROM collaboration_invitations WHERE id=${invitationId} FOR UPDATE`) + if (invite.target_principal_id !== principal.id || (principal.kind === "guest" && invite.token_hash !== hash(text(token, 100)))) fail("Einladung nicht verfügbar", 404) + if (invite.status === "accepted") { + await this.membership(db, room, principal) + return { roomId: room.id } + } + if (invite.status !== "pending" || new Date(invite.expires_at).getTime() <= Date.now() + || (invite.access_expires_at && new Date(invite.access_expires_at).getTime() <= Date.now())) fail("Einladung ist abgelaufen oder widerrufen", 410) + const inviter = await this.currentUser(invite.invited_by, Number(room.tenant_id), db) + if (!inviter.manage) fail("Einladung ist nicht mehr gültig", 410) + await db.execute(sql`INSERT INTO collaboration_members(room_id,principal_id,role,overview_allowed,history_from_seq,expires_at) + VALUES (${room.id},${principal.id},${invite.role},${invite.overview_allowed},${Number(room.sequence) + 1},${invite.access_expires_at}) + ON CONFLICT (room_id,principal_id) DO UPDATE SET status='active',role=excluded.role,overview_allowed=excluded.overview_allowed, + history_from_seq=excluded.history_from_seq,expires_at=excluded.expires_at,revision=collaboration_members.revision+1,revoked_at=null,joined_at=now()`) + await db.execute(sql`UPDATE collaboration_invitations SET status='accepted',accepted_at=now() WHERE id=${invite.id}`) + await this.audit(db, room, principal, "invitation.accepted", invite.id) + await this.options.changed?.(room.id, db) + return { roomId: room.id } + }) + } + + async decline(principal: Principal, invitationId: string) { + await this.assertPrincipal(principal) + await this.db.execute(sql`UPDATE collaboration_invitations SET status='declined' WHERE id=${uuid(invitationId)} AND target_principal_id=${principal.id} AND status='pending'`) + return { declined: true } + } + + async management(ctx: UserContext, roomId: string) { + const room = await this.roomRow(this.db, roomId) + await this.manager(this.db, ctx, room) + return { + members: await rows(this.db, sql`SELECT m.id,m.principal_id AS "principalId",p.display_name AS name,p.kind,p.email, + m.status,m.role,m.overview_allowed AS "overviewAllowed",m.expires_at AS "expiresAt" + FROM collaboration_members m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.room_id=${room.id} ORDER BY m.joined_at`), + invitations: await rows(this.db, sql`SELECT i.id,p.display_name AS name,p.kind,i.status,i.expires_at AS "expiresAt" + FROM collaboration_invitations i JOIN collaboration_principals p ON p.id=i.target_principal_id WHERE i.room_id=${room.id} ORDER BY i.created_at DESC LIMIT 100`), + } + } + + async revoke(ctx: UserContext, roomId: string, subjectId: string, invitation = false) { + return this.db.transaction(async (db) => { + const room = await this.roomRow(db, roomId, true) + await this.manager(db, ctx, room) + if (invitation) { + await db.execute(sql`UPDATE collaboration_invitations SET status='revoked' WHERE id=${uuid(subjectId)} AND room_id=${room.id} AND status='pending'`) + } else { + const [member] = await rows(db, sql`UPDATE collaboration_members SET status='revoked',revision=revision+1,revoked_at=now() + WHERE id=${uuid(subjectId)} AND room_id=${room.id} RETURNING principal_id`) + if (member) await db.execute(sql`UPDATE collaboration_invitations SET status='revoked' + WHERE room_id=${room.id} AND target_principal_id=${member.principal_id} AND status='pending'`) + } + await this.audit(db, room, ctx.principal, invitation ? "invitation.revoked" : "member.revoked", subjectId) + await this.options.changed?.(room.id, db) + return { revoked: true } + }) + } + + async updateMember(ctx: UserContext, roomId: string, memberId: string, input: any) { + if (Object.keys(input).some(key => !["role", "overviewAllowed", "expiresAt"].includes(key))) fail("Unbekanntes Berechtigungsfeld") + if (!["participant", "reader"].includes(input.role) || typeof input.overviewAllowed !== "boolean") fail("Ungültige Berechtigung") + return this.db.transaction(async db => { + const room = await this.roomRow(db, roomId, true) + await this.manager(db, ctx, room) + const [member] = await rows(db, sql`SELECT * FROM collaboration_members WHERE id=${uuid(memberId)} AND room_id=${room.id} + AND status='active' AND (expires_at IS NULL OR expires_at>now())`) + if (!member) fail("Teilnahme ist nicht mehr aktiv. Bitte erneut einladen.", 410) + await db.execute(sql`UPDATE collaboration_members SET role=${input.role},overview_allowed=${input.overviewAllowed}, + expires_at=${input.expiresAt === undefined ? member.expires_at : futureDate(input.expiresAt)},revision=revision+1 WHERE id=${member.id}`) + await this.audit(db, room, ctx.principal, "member.updated", member.id) + await this.options.changed?.(room.id, db) + return { updated: true } + }) + } + + async rateLimit(key: string, limit: number, seconds: number) { + const [counter] = await rows(this.db, sql`INSERT INTO collaboration_rate_limits(key,hits,resets_at) + VALUES (${hash(key)},1,now()+${seconds}*interval '1 second') ON CONFLICT (key) DO UPDATE + SET hits=CASE WHEN collaboration_rate_limits.resets_at<=now() THEN 1 ELSE collaboration_rate_limits.hits+1 END, + resets_at=CASE WHEN collaboration_rate_limits.resets_at<=now() THEN excluded.resets_at ELSE collaboration_rate_limits.resets_at END RETURNING hits`) + if (counter.hits > limit) fail("Zu viele Anfragen. Bitte später erneut versuchen.", 429) + } + + codeHash(id: string, code: string) { + const pepper = this.options.guestPepper() + if (pepper.length < 32) fail("Gastanmeldung ist noch nicht konfiguriert", 503) + return createHmac("sha256", pepper).update(`fedeo-guest-code-v1:${id}:${code}`).digest("hex") + } + + async requestGuestCode(input: any, ip: string) { + await this.rateLimit(`guest-request:${ip}`, 10, 600) + let guest: Principal | undefined + if (input.invitationToken) { + const [row] = await rows(this.db, sql`SELECT p.*,i.id AS invitation_id FROM collaboration_invitations i + JOIN collaboration_principals p ON p.id=i.target_principal_id JOIN collaboration_rooms r ON r.id=i.room_id + LEFT JOIN projects project ON project.id=r.project_id + WHERE i.token_hash=${hash(text(input.invitationToken, 100))} AND i.status='pending' AND i.expires_at>now() + AND p.kind='guest' AND NOT p.disabled AND r.status='active' AND NOT coalesce(project.archived,false)`) + guest = row + } else { + const address = email(input.email) + const [row] = await rows(this.db, sql`SELECT p.* FROM collaboration_principals p + JOIN collaboration_workspaces w ON w.tenant_id=p.tenant_id + WHERE w.public_id=${uuid(input.workspaceId)} AND w.guests_enabled AND p.kind='guest' AND p.email=${address} AND NOT p.disabled + AND EXISTS(SELECT 1 FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id + WHERE m.principal_id=p.id AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now()) AND r.status<>'blocked')`) + guest = row + } + const challengeId = randomUUID() + if (!guest) return { challengeId } + await this.assertPrincipal(guest) + await this.rateLimit(`guest-mail:${guest.id}`, 3, 600) + const code = String(randomInt(10000000, 100000000)) + await this.db.execute(sql`INSERT INTO collaboration_guest_challenges(id,principal_id,code_hash,expires_at) + VALUES (${challengeId},${guest.id},${this.codeHash(challengeId, code)},now()+interval '10 minutes')`) + const delivery = await this.options.sendMail(guest.email!, "Dein FEDEO-Anmeldecode", `

Dein Anmeldecode lautet ${code}.

Er ist zehn Minuten gültig. Gib ihn nur in der FEDEO-Gastanmeldung ein.

`) + if (!delivery.success) { + await this.db.execute(sql`DELETE FROM collaboration_guest_challenges WHERE id=${challengeId}`) + fail("Anmeldecode konnte nicht zugestellt werden", 503) + } + return { challengeId } + } + + async verifyGuestCode(input: any, ip: string) { + await this.rateLimit(`guest-verify:${ip}`, 30, 600) + const id = uuid(input.challengeId), code = text(input.code, 32) + const result = await this.db.transaction(async (db) => { + const [challenge] = await rows(db, sql`SELECT * FROM collaboration_guest_challenges WHERE id=${id} FOR UPDATE`) + if (!challenge || challenge.consumed_at || challenge.attempts >= 5 || new Date(challenge.expires_at).getTime() <= Date.now()) return null + await db.execute(sql`UPDATE collaboration_guest_challenges SET attempts=attempts+1 WHERE id=${id}`) + if (!timingSafeEqual(Buffer.from(challenge.code_hash, "hex"), Buffer.from(this.codeHash(id, code), "hex"))) return null + const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${challenge.principal_id}`) + await this.assertPrincipal(principal, db) + await db.execute(sql`UPDATE collaboration_guest_challenges SET consumed_at=now() WHERE id=${id}`) + const token = randomBytes(32).toString("base64url") + await db.execute(sql`INSERT INTO collaboration_guest_sessions(token_hash,principal_id,expires_at) + VALUES (${hash(token)},${principal.id},now()+interval '12 hours')`) + const workspace = await this.workspace(Number(principal.tenant_id), db) + return { token, workspaceId: workspace.public_id, name: principal.display_name } + }) + if (!result) fail("Anmeldecode ist ungültig oder abgelaufen", 401) + return result + } + + async guestSession(token: string): Promise { + if (!/^[A-Za-z0-9_-]{43}$/.test(token || "")) fail("Gastanmeldung erforderlich", 401) + const [principal] = await rows(this.db, sql`SELECT p.* FROM collaboration_guest_sessions s + JOIN collaboration_principals p ON p.id=s.principal_id WHERE s.token_hash=${hash(token)} AND s.expires_at>now() AND p.kind='guest'`) + if (!principal) fail("Gastanmeldung erforderlich", 401) + return this.assertPrincipal(principal) + } + + async logoutGuest(token: string) { + await this.db.execute(sql`DELETE FROM collaboration_guest_sessions WHERE token_hash=${hash(token)}`) + return { loggedOut: true } + } + + async guestInvitation(principal: Principal, token: string) { + const [invite] = await rows(this.db, sql`SELECT i.id,r.name,r.topic,i.role,i.overview_allowed AS "overviewAllowed",i.expires_at AS "expiresAt",i.status + FROM collaboration_invitations i JOIN collaboration_rooms r ON r.id=i.room_id + WHERE i.token_hash=${hash(text(token, 100))} AND i.target_principal_id=${principal.id}`) + if (!invite) fail("Einladung nicht verfügbar", 404) + return invite + } +} diff --git a/backend/src/modules/collaboration/types.ts b/backend/src/modules/collaboration/types.ts new file mode 100644 index 00000000..2d56086c --- /dev/null +++ b/backend/src/modules/collaboration/types.ts @@ -0,0 +1,47 @@ +import { createHash } from "node:crypto" + +export type Principal = { + id: string; kind: "user" | "guest" | "remote"; tenant_id: number | string | null; + user_id: string | null; remote_origin: string | null; remote_actor_id: string | null; + display_name: string; email?: string | null; disabled: boolean; +} +export type UserContext = { userId: string; tenantId: number; principal: Principal; manage: boolean } +export type RoomStatus = "active" | "archived" | "blocked" +export const hash = (value: string) => createHash("sha256").update(value).digest("hex") +export function fail(message: string, statusCode = 400): never { + throw Object.assign(new Error(message), { statusCode }) +} +export function uuid(value: unknown): string { + if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value)) fail("Ungültige ID") + return value.toLowerCase() +} +export function text(value: unknown, max = 160, optional = false): string { + if (optional && (value === undefined || value === null)) return "" + if (typeof value !== "string" || (!optional && !value.trim()) || value.trim().length > max) fail("Ungültiger Text") + return value.trim() +} +export function email(value: unknown): string { + const result = text(value, 254).toLowerCase() + if (!/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(result)) fail("Ungültige E-Mail-Adresse") + return result +} +export function overview(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) fail("Ungültige Projektübersicht") + const allowed = new Set(["title", "status", "date", "description"]) + return Object.fromEntries(Object.entries(value).map(([key, entry]) => { + if (!allowed.has(key)) fail("Unbekanntes Feld in der Projektübersicht") + return [key, text(entry, key === "description" ? 2000 : 160, true)] + })) +} +export function futureDate(value: unknown, maxDays = 365): Date | null { + if (!value) return null + const date = new Date(String(value)) + if (!Number.isFinite(date.getTime()) || date.getTime() <= Date.now() || date.getTime() > Date.now() + maxDays * 86400000) fail("Ungültiges Ablaufdatum") + return date +} +export function sequence(value: unknown): number { + const n = Number(value || 0) + if (!Number.isSafeInteger(n) || n < 0) fail("Ungültige Nachrichtenposition") + return n +} +export const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!)) diff --git a/backend/src/routes/collaboration.ts b/backend/src/routes/collaboration.ts new file mode 100644 index 00000000..073e496d --- /dev/null +++ b/backend/src/routes/collaboration.ts @@ -0,0 +1,131 @@ +import type { FastifyInstance } from "fastify" +import { sql } from "drizzle-orm" +import { CollaborationService, rows } from "../modules/collaboration/service" +import { escapeHtml, fail, Principal, text, UserContext, uuid } from "../modules/collaboration/types" + +export type CollaborationFederation = { + contact(principal: Principal): string; + resolveContact(ctx: UserContext, address: string): Promise; + invite(ctx: UserContext, roomId: string, target: Principal, invitationId: string): Promise; + list(ctx: UserContext): Promise<{ rooms: any[]; invitations: any[] }>; + memberRequest(ctx: UserContext, bindingId: string, action: string, input?: any): Promise<{ found: boolean; result?: any }>; + accept(ctx: UserContext, id: string, decline?: boolean): Promise<{ found: boolean; result?: any }>; + settings(ctx: UserContext, input?: any): Promise; +} +export type CollaborationRouteOptions = { service: CollaborationService; federation?: CollaborationFederation; apiUrl: string } + +function body(req: any) { + if (!req.body || typeof req.body !== "object" || Array.isArray(req.body)) fail("Ungültige Anfrage") + return req.body +} + +export async function collaborationRoutes(server: FastifyInstance, options: CollaborationRouteOptions) { + const { service, federation } = options + const context = (req: any) => service.userContext(req.user?.user_id, Number(req.user?.tenant_id)) + server.addHook("onSend", async (req, reply) => { reply.header("Cache-Control", "no-store") }) + + server.get("/collaboration", async (req) => { + const ctx = await context(req) + const remote = federation ? await federation.list(ctx) : { rooms: [], invitations: [] } + return { rooms: [...await service.listRooms(ctx.principal), ...remote.rooms], + invitations: [...await service.invitations(ctx.principal), ...remote.invitations], + managedRooms: await service.managedRooms(ctx), projects: await service.projects(ctx), + canManage: ctx.manage, contact: federation?.contact(ctx.principal) || `${options.apiUrl}/federation/actors/${ctx.principal.id}` } + }) + server.get("/collaboration/contacts", async (req) => ({ contacts: await service.localContacts(await context(req)) })) + server.post("/collaboration/rooms", async (req) => service.createRoom(await context(req), body(req))) + server.patch("/collaboration/rooms/:id", async (req: any) => service.updateRoom(await context(req), req.params.id, body(req))) + server.get("/collaboration/rooms/:id/management", async (req: any) => service.management(await context(req), req.params.id)) + server.post("/collaboration/rooms/:id/invitations", async (req: any) => { + const ctx = await context(req), input = body(req) + // Check ownership before resolving or creating any recipient identity. + await service.manager(service.db, ctx, await service.roomRow(service.db, req.params.id)) + let target: Principal + if (input.kind === "guest") { + if (!service.options.publicUrl) fail("Die öffentliche FEDEO-Adresse ist noch nicht konfiguriert", 503) + target = await service.guest(ctx, input) + } + else if (input.kind === "user") target = (await service.userContext(uuid(input.userId), ctx.tenantId)).principal + else if (input.kind === "contact") { + const address = text(input.contact, 1000) + if (federation) target = await federation.resolveContact(ctx, address) + else { + const prefix = `${options.apiUrl}/federation/actors/` + if (!address.startsWith(prefix)) fail("Föderation ist noch nicht konfiguriert", 503) + const [principal] = await rows(service.db, sql`SELECT * FROM collaboration_principals WHERE id=${uuid(address.slice(prefix.length))} AND kind='user'`) + if (!principal) fail("Kontakt nicht gefunden", 404) + target = await service.assertPrincipal(principal) + } + } else fail("Ungültige Teilnehmerart") + await service.rateLimit(`invite:${ctx.tenantId}:${ctx.userId}`, 30, 600) + const invitation = await service.invite(ctx, req.params.id, target, input) + if (target.kind === "remote") await federation!.invite(ctx, req.params.id, target, invitation.id) + const url = target.kind === "guest" ? `${service.options.publicUrl}/guest#invite=${invitation.token}` : null + let mailSent = false + if (url && input.sendEmail === true) { + const delivered = await service.options.sendMail(target.email!, "Einladung zur FEDEO-Zusammenarbeit", + `

Du wurdest in einen FEDEO-Projekt-Unterraum eingeladen.

Einladung öffnen

Zum Anmelden erhältst du einen separaten Code an diese E-Mail-Adresse.

`) + mailSent = delivered.success + } + return { id: invitation.id, expiresAt: invitation.expiresAt, url, mailSent } + }) + server.post("/collaboration/invitations/:id/accept", async (req: any) => { + const ctx = await context(req) + const remote = await federation?.accept(ctx, uuid(req.params.id)) + return remote?.found ? remote.result : service.accept(ctx.principal, req.params.id) + }) + server.post("/collaboration/invitations/:id/decline", async (req: any) => { + const ctx = await context(req) + const remote = await federation?.accept(ctx, uuid(req.params.id), true) + return remote?.found ? remote.result : service.decline(ctx.principal, req.params.id) + }) + server.delete("/collaboration/rooms/:id/members/:memberId", async (req: any) => service.revoke(await context(req), req.params.id, req.params.memberId)) + server.patch("/collaboration/rooms/:id/members/:memberId", async (req: any) => service.updateMember(await context(req), req.params.id, req.params.memberId, body(req))) + server.delete("/collaboration/rooms/:id/invitations/:invitationId", async (req: any) => service.revoke(await context(req), req.params.id, req.params.invitationId, true)) + server.get("/collaboration/federation", async (req) => { + const ctx = await context(req) + if (!ctx.manage) fail("Keine Berechtigung", 403) + return federation ? federation.settings(ctx) : { enabled: false, partners: [] } + }) + server.post("/collaboration/federation", async (req) => { + const ctx = await context(req) + if (!ctx.manage) fail("Keine Berechtigung", 403) + if (!federation) fail("Föderation ist noch nicht konfiguriert", 503) + return federation.settings(ctx, body(req)) + }) + const request = async (req: any, action: string) => { + const ctx = await context(req), id = uuid(req.params.id) + const input = req.method === "GET" ? req.query : body(req) + const remote = await federation?.memberRequest(ctx, id, action, input) + if (remote?.found) return remote.result + if (action === "room") return service.room(ctx.principal, id) + if (action === "messages") return service.messages(ctx.principal, id, input) + if (action === "send") return service.send(ctx.principal, id, input) + if (action === "read") return service.markRead(ctx.principal, id, input.seq) + return { members: await service.members(ctx.principal, id) } + } + server.get("/collaboration/rooms/:id", async (req) => request(req, "room")) + server.get("/collaboration/rooms/:id/messages", async (req) => request(req, "messages")) + server.post("/collaboration/rooms/:id/messages", async (req) => request(req, "send")) + server.post("/collaboration/rooms/:id/read", async (req) => request(req, "read")) + server.get("/collaboration/rooms/:id/members", async (req) => request(req, "members")) +} + +export async function collaborationGuestRoutes(server: FastifyInstance, { service }: CollaborationRouteOptions) { + server.addHook("onSend", async (req, reply) => { + reply.header("Cache-Control", "no-store").header("Referrer-Policy", "no-referrer") + }) + const token = (req: any) => String(req.headers.authorization || "").replace(/^Bearer /, "") + const principal = (req: any) => service.guestSession(token(req)) + server.post("/guest/api/auth/request", { bodyLimit: 4096 }, async (req) => service.requestGuestCode(body(req), req.ip)) + server.post("/guest/api/auth/verify", { bodyLimit: 4096 }, async (req) => service.verifyGuestCode(body(req), req.ip)) + server.post("/guest/api/logout", async (req) => service.logoutGuest(token(req))) + server.get("/guest/api/rooms", async (req) => ({ rooms: await service.listRooms(await principal(req)) })) + server.post("/guest/api/invitation", async (req) => service.guestInvitation(await principal(req), body(req).token)) + server.post("/guest/api/invitations/:id/accept", async (req: any) => service.accept(await principal(req), uuid(req.params.id), body(req).token)) + server.get("/guest/api/rooms/:id", async (req: any) => service.room(await principal(req), req.params.id)) + server.get("/guest/api/rooms/:id/messages", async (req: any) => service.messages(await principal(req), req.params.id, req.query)) + server.get("/guest/api/rooms/:id/members", async (req: any) => ({ members: await service.members(await principal(req), req.params.id) })) + server.post("/guest/api/rooms/:id/messages", async (req: any) => service.send(await principal(req), req.params.id, body(req))) + server.post("/guest/api/rooms/:id/read", async (req: any) => service.markRead(await principal(req), req.params.id, body(req).seq)) +} diff --git a/backend/tests/collaboration.test.ts b/backend/tests/collaboration.test.ts new file mode 100644 index 00000000..b641020a --- /dev/null +++ b/backend/tests/collaboration.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { randomUUID } from "node:crypto" +import { CollaborationService } from "../src/modules/collaboration/service" +import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db" + +test("project subrooms isolate tenants, guests, history and invitation lifecycles", { skip: !testDatabaseUrl }, async (t) => { + const fixture = await collaborationDatabase(["0075_project_collaboration.sql"]) + const mails: { to: string; html: string }[] = [] + const service = new CollaborationService(fixture.db, { + publicUrl: "https://owner.example", guestPepper: () => "a".repeat(64), + sendMail: async (to, subject, html) => { mails.push({ to, html }); return { success: true } }, + }) + const ownerId = randomUUID(), externalId = randomUUID(), colleagueId = randomUUID() + try { + await fixture.pool.query("INSERT INTO tenants VALUES (1,'Owner',null),(2,'Partner',null)") + await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,'owner@example.test',true),($2,'external@example.test',false),($3,'colleague@example.test',false)", [ownerId, externalId, colleagueId]) + await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$2),(1,$2),(1,$3)", [ownerId, externalId, colleagueId]) + await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived,notes) VALUES (1,1,'Secret project','{}',false,'Secret calculation'),(2,2,'Foreign project','{}',false,'Private')") + const owner = await service.userContext(ownerId, 1) + const external = await service.userContext(externalId, 2) + const externalOtherTenant = await service.userContext(externalId, 1) + const colleague = await service.userContext(colleagueId, 1) + const room = await service.createRoom(owner, { projectId: 1, name: "Gerüstbau" }) + const sibling = await service.createRoom(owner, { projectId: 1, name: "Bauherr" }) + + await t.test("creation requires project authority and enforces owner tenant at SQL level", async () => { + await assert.rejects(service.createRoom(colleague, { projectId: 1, name: "Leak" }), /Berechtigung/) + await assert.rejects(service.createRoom(owner, { projectId: 2, name: "Leak" }), /Projekt nicht/) + await assert.rejects(fixture.pool.query("INSERT INTO collaboration_rooms(tenant_id,project_id,name,created_by) VALUES (1,2,'Leak',$1)", [ownerId]), /another tenant/) + await assert.rejects(fixture.pool.query("UPDATE collaboration_rooms SET project_id=null WHERE id=$1", [room.id]), /immutable/) + }) + + await service.send(owner.principal, room.id, { clientId: randomUUID(), text: "Before invitation" }) + const invitation = await service.invite(owner, room.id, external.principal, { overviewAllowed: true }) + await t.test("invitation binds exact user and tenant and grants no project or sibling access", async () => { + assert.equal((await service.invitations(external.principal)).length, 1) + assert.equal((await service.invitations(externalOtherTenant.principal)).length, 0) + await assert.rejects(service.accept(externalOtherTenant.principal, invitation.id), /nicht verfügbar/) + await assert.rejects(service.messages(external.principal, room.id), /kein Zugriff/) + await service.accept(external.principal, invitation.id) + await service.accept(external.principal, invitation.id) + assert.deepEqual((await service.messages(external.principal, room.id)).messages, []) + assert.deepEqual((await service.listRooms(external.principal)).map((r) => r.id), [room.id]) + await assert.rejects(service.room(external.principal, sibling.id), /kein Zugriff/) + await assert.rejects(service.management(external, room.id), /Berechtigung/) + assert.equal("projectId" in await service.room(external.principal, room.id), false) + }) + + await t.test("messages are ordered, retried idempotently and paginated", async () => { + const clientId = randomUUID() + const [first, second] = await Promise.all([ + service.send(external.principal, room.id, { clientId, text: "Hello" }), + service.send(external.principal, room.id, { clientId, text: "Hello" }), + ]) + assert.equal(first.id, second.id) + assert.equal(first.seq, 2) + await assert.rejects(service.send(external.principal, room.id, { clientId, text: "Different" }), /Auftrags-ID/) + for (let i = 0; i < 53; i++) await service.send(owner.principal, room.id, { clientId: randomUUID(), text: `Message ${i}` }) + const latest = await service.messages(external.principal, room.id) + assert.equal(latest.messages.length, 50) + assert.equal(latest.messages[49].seq, 55) + const earlier = await service.messages(external.principal, room.id, { before: latest.messages[0].seq }) + assert.equal(earlier.messages[0].seq, 2) + await service.markRead(external.principal, room.id, 9999) + await service.markRead(external.principal, room.id, 1) + assert.equal((await service.room(external.principal, room.id)).lastReadSeq, 55) + }) + + await t.test("shared overview is an explicit field projection", async () => { + await assert.rejects(service.updateRoom(owner, room.id, { overview: { notes: "Secret" } }), /Unbekanntes Feld/) + await service.updateRoom(owner, room.id, { overview: { title: "External title", date: "2026-10-01" } }) + assert.deepEqual((await service.room(external.principal, room.id)).overview, { title: "External title", date: "2026-10-01" }) + await assert.rejects(service.updateRoom(owner, room.id, { projectId: 2 }), /Unbekanntes/) + }) + + await t.test("guest verifies invited address and gets only the accepted room", async () => { + const guest = await service.guest(owner, { email: "guest@example.test", name: "Guest" }) + const invite = await service.invite(owner, sibling.id, guest, { role: "reader" }) + const challenge = await service.requestGuestCode({ invitationToken: invite.token }, "test-guest") + assert.equal(mails[0].to, "guest@example.test") + const code = mails[0].html.match(/(\d+)<\/strong>/)![1] + await assert.rejects(service.verifyGuestCode({ challengeId: challenge.challengeId, code: "wrong" }, "test-guest"), /ungültig/) + const session = await service.verifyGuestCode({ challengeId: challenge.challengeId, code }, "test-guest") + await assert.rejects(service.verifyGuestCode({ challengeId: challenge.challengeId, code }, "test-guest"), /ungültig/) + const authenticated = await service.guestSession(session.token) + await assert.rejects(service.accept(authenticated, invite.id, "bad-token"), /nicht verfügbar/) + await service.accept(authenticated, invite.id, invite.token) + assert.deepEqual((await service.listRooms(authenticated)).map((r) => r.id), [sibling.id]) + assert.equal((await service.room(authenticated, sibling.id)).overview, null) + await assert.rejects(service.send(authenticated, sibling.id, { clientId: randomUUID(), text: "No" }), /nur lesbar/) + await assert.rejects(service.room(authenticated, room.id), /kein Zugriff/) + const login = await service.requestGuestCode({ workspaceId: session.workspaceId, email: "guest@example.test" }, "test-login") + for (let i = 0; i < 5; i++) await assert.rejects(service.verifyGuestCode({ challengeId: login.challengeId, code: "wrong" }, "test-login")) + const correct = mails[1].html.match(/(\d+)<\/strong>/)![1] + await assert.rejects(service.verifyGuestCode({ challengeId: login.challengeId, code: correct }, "test-login")) + const guestMembership = (await service.management(owner, sibling.id)).members.find((m) => m.principalId === guest.id) + await service.revoke(owner, sibling.id, guestMembership.id) + await assert.rejects(service.room(authenticated, sibling.id), /kein Zugriff/) + await assert.rejects(service.accept(authenticated, invite.id, invite.token), /kein Zugriff/) + await service.logoutGuest(session.token) + await assert.rejects(service.guestSession(session.token), /Gastanmeldung/) + }) + + await t.test("expired and withdrawn invitations cannot add a member", async () => { + const invite = await service.invite(owner, sibling.id, colleague.principal, {}) + await service.revoke(owner, sibling.id, invite.id, true) + await assert.rejects(service.accept(colleague.principal, invite.id), /widerrufen/) + const expired = await service.invite(owner, sibling.id, colleague.principal, {}) + await fixture.pool.query("UPDATE collaboration_invitations SET expires_at=now()-interval '1 minute' WHERE id=$1", [expired.id]) + await assert.rejects(service.accept(colleague.principal, expired.id), /abgelaufen/) + }) + + await t.test("revocation blocks old accept/retry and reinvitation resets history", async () => { + const member = (await service.management(owner, room.id)).members.find((m) => m.principalId === external.principal.id) + await service.revoke(owner, room.id, member.id) + await assert.rejects(service.messages(external.principal, room.id), /kein Zugriff/) + await assert.rejects(service.accept(external.principal, invitation.id), /kein Zugriff/) + const reInvite = await service.invite(owner, room.id, external.principal, {}) + await service.accept(external.principal, reInvite.id) + assert.deepEqual((await service.messages(external.principal, room.id)).messages, []) + await service.updateRoom(owner, room.id, { status: "archived" }) + await assert.rejects(service.send(external.principal, room.id, { clientId: randomUUID(), text: "No" }), /archiviert/) + await service.messages(external.principal, room.id) + await service.updateRoom(owner, room.id, { status: "blocked" }) + await assert.rejects(service.messages(external.principal, room.id), /gesperrt/) + }) + } finally { await fixture.close() } +}) diff --git a/backend/tests/collaborationRoutes.test.ts b/backend/tests/collaborationRoutes.test.ts new file mode 100644 index 00000000..b6dbf6c4 --- /dev/null +++ b/backend/tests/collaborationRoutes.test.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { randomUUID } from "node:crypto" +import Fastify from "fastify" +import jwt from "jsonwebtoken" +import cookie from "@fastify/cookie" +import authPlugin from "../src/plugins/auth" +import { secrets } from "../src/utils/secrets" +import { collaborationRoutes, collaborationGuestRoutes } from "../src/routes/collaboration" +import { CollaborationService } from "../src/modules/collaboration/service" +import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db" + +test("HTTP guest sessions never authorize normal APIs or other subrooms", { skip: !testDatabaseUrl }, async () => { + const fixture = await collaborationDatabase(["0075_project_collaboration.sql"]) + const app = Fastify(), ownerId = randomUUID(), mail: string[] = [] + const previousSecret = secrets.JWT_SECRET + secrets.JWT_SECRET = "collaboration-test-only-secret-" + randomUUID() + const service = new CollaborationService(fixture.db, { publicUrl: "https://owner.example", guestPepper: () => "p".repeat(64), + sendMail: async (to, subject, html) => { mail.push(html); return { success: true } } }) + const options = { service, apiUrl: "https://owner.example/backend" } + try { + await fixture.pool.query("INSERT INTO tenants VALUES (1,'Owner',null)") + await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,'owner@example.test',true)", [ownerId]) + await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)", [ownerId]) + await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles,archived) VALUES (1,1,'Internal','{}',false)") + app.decorate("db", fixture.db) + await app.register(cookie) + await app.register(collaborationGuestRoutes, options) + await app.register(async protectedApp => { + await protectedApp.register(authPlugin) + await protectedApp.register(collaborationRoutes, options) + }, { prefix: "/api" }) + const ownerToken = jwt.sign({ user_id: ownerId, tenant_id: 1, is_admin: true }, secrets.JWT_SECRET) + const headers = { authorization: `Bearer ${ownerToken}` } + const created = await app.inject({ method: "POST", url: "/api/collaboration/rooms", headers, payload: { projectId: 1, name: "Guest room" } }) + assert.equal(created.statusCode, 200, created.body) + const roomId = created.json().id + const invited = await app.inject({ method: "POST", url: `/api/collaboration/rooms/${roomId}/invitations`, headers, + payload: { kind: "guest", email: "guest@example.test", name: "Guest", sendEmail: false } }) + assert.equal(invited.statusCode, 200, invited.body) + const token = new URL(invited.json().url).hash.slice("#invite=".length) + const requested = await app.inject({ method: "POST", url: "/guest/api/auth/request", payload: { invitationToken: token } }) + assert.equal(requested.statusCode, 200, requested.body) + const verified = await app.inject({ method: "POST", url: "/guest/api/auth/verify", payload: { + challengeId: requested.json().challengeId, code: mail[0].match(/(\d+)<\/strong>/)![1], + } }) + assert.equal(verified.statusCode, 200, verified.body) + const guestHeaders = { authorization: `Bearer ${verified.json().token}` } + const accepted = await app.inject({ method: "POST", url: `/guest/api/invitations/${invited.json().id}/accept`, headers: guestHeaders, payload: { token } }) + assert.equal(accepted.statusCode, 200, accepted.body) + assert.equal((await app.inject({ url: "/api/collaboration", headers: guestHeaders })).statusCode, 401) + assert.equal((await app.inject({ url: "/guest/api/rooms", headers })).statusCode, 401) + assert.equal((await app.inject({ url: `/guest/api/rooms/${roomId}`, headers: guestHeaders })).statusCode, 200) + assert.equal((await app.inject({ url: `/guest/api/rooms/${randomUUID()}`, headers: guestHeaders })).statusCode, 404) + const sent = await app.inject({ method: "POST", url: `/guest/api/rooms/${roomId}/messages`, headers: guestHeaders, + payload: { text: "Guest message", clientId: randomUUID(), tenantId: 999, principalId: ownerId } }) + assert.equal(sent.statusCode, 200, sent.body) + const stored = await fixture.pool.query("SELECT p.kind FROM collaboration_messages m JOIN collaboration_principals p ON p.id=m.principal_id WHERE m.id=$1", [sent.json().id]) + assert.equal(stored.rows[0].kind, "guest") + assert.equal((await app.inject({ method: "POST", url: "/guest/api/logout", headers: guestHeaders })).statusCode, 200) + assert.equal((await app.inject({ url: "/guest/api/rooms", headers: guestHeaders })).statusCode, 401) + } finally { secrets.JWT_SECRET = previousSecret; await app.close(); await fixture.close() } +}) diff --git a/backend/tests/helpers/collaboration-db.ts b/backend/tests/helpers/collaboration-db.ts index 6899a00f..057e4d4c 100644 --- a/backend/tests/helpers/collaboration-db.ts +++ b/backend/tests/helpers/collaboration-db.ts @@ -30,7 +30,7 @@ export async function collaborationDatabase(migrations: string[] = []) { }) await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`) } - await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, name text, locked text)`) + await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, name text, locked text, locked_by_export_job_id uuid)`) await pool.query(`CREATE UNIQUE INDEX ON communication_rooms (tenant_id, key)`) await pool.query(await readFile(new URL("../../db/migrations/0070_native_communication_chat.sql", import.meta.url), "utf8")) for (const name of migrations) { diff --git a/frontend/components/CollaborationRoom.vue b/frontend/components/CollaborationRoom.vue new file mode 100644 index 00000000..ae87ac1e --- /dev/null +++ b/frontend/components/CollaborationRoom.vue @@ -0,0 +1,121 @@ + + +