From 7e6c2fbac5dcb5deeeace0aea5e001d5ffad3e1a Mon Sep 17 00:00:00 2001 From: flfeders Date: Fri, 2 Oct 2026 18:27:02 +0200 Subject: [PATCH] feat(communication): add room visibility and project chat controls --- .../db/migrations/0078_chat_visibility.sql | 8 ++ backend/db/migrations/meta/_journal.json | 7 ++ .../db/schema/communication_hidden_rooms.ts | 10 ++ backend/db/schema/index.ts | 1 + backend/db/schema/projects.ts | 2 + backend/src/modules/communication-access.ts | 11 +- backend/src/routes/communication.ts | 23 ++++ backend/src/routes/resources/main.ts | 10 ++ backend/tests/chatVisibility.test.ts | 110 ++++++++++++++++++ backend/tests/helpers/collaboration-db.ts | 2 +- docs/chat-foederation-konzept.md | 4 +- frontend/pages/communication/chat.vue | 97 ++++++++++++--- frontend/stores/data.js | 10 ++ frontend/tests/chat-collaboration.test.cjs | 90 ++++++++++++++ 14 files changed, 364 insertions(+), 21 deletions(-) create mode 100644 backend/db/migrations/0078_chat_visibility.sql create mode 100644 backend/db/schema/communication_hidden_rooms.ts create mode 100644 backend/tests/chatVisibility.test.ts diff --git a/backend/db/migrations/0078_chat_visibility.sql b/backend/db/migrations/0078_chat_visibility.sql new file mode 100644 index 00000000..9f17b736 --- /dev/null +++ b/backend/db/migrations/0078_chat_visibility.sql @@ -0,0 +1,8 @@ +ALTER TABLE "projects" ADD COLUMN IF NOT EXISTS "chat_enabled" boolean NOT NULL DEFAULT true; +--> statement-breakpoint +CREATE TABLE "communication_hidden_rooms" ( + "tenant_id" bigint NOT NULL REFERENCES "tenants"("id") ON DELETE CASCADE, + "user_id" uuid NOT NULL REFERENCES "auth_users"("id") ON DELETE CASCADE, + "room_key" text NOT NULL, + PRIMARY KEY ("tenant_id", "user_id", "room_key") +); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 91cac318..7d2076b4 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -526,6 +526,13 @@ "when": 1790899200000, "tag": "0077_scanner_inbox_preparation", "breakpoints": true + }, + { + "idx": 75, + "version": "7", + "when": 1790985600000, + "tag": "0078_chat_visibility", + "breakpoints": true } ] } diff --git a/backend/db/schema/communication_hidden_rooms.ts b/backend/db/schema/communication_hidden_rooms.ts new file mode 100644 index 00000000..7e3c6d76 --- /dev/null +++ b/backend/db/schema/communication_hidden_rooms.ts @@ -0,0 +1,10 @@ +import { bigint, pgTable, primaryKey, text, uuid } from "drizzle-orm/pg-core" +import { authUsers } from "./auth_users" +import { tenants } from "./tenants" + +// Room keys also cover collaboration bindings and not-yet-provisioned chats. +export const communicationHiddenRooms = pgTable("communication_hidden_rooms", { + tenantId: bigint("tenant_id", { mode: "number" }).notNull().references(() => tenants.id, { onDelete: "cascade" }), + userId: uuid("user_id").notNull().references(() => authUsers.id, { onDelete: "cascade" }), + roomKey: text("room_key").notNull(), +}, table => ({ pk: primaryKey({ columns: [table.tenantId, table.userId, table.roomKey] }) })) diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 399dbae2..a5ed364c 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -19,6 +19,7 @@ export * from "./communication_rooms" export * from "./communication_room_members" export * from "./communication_messages" export * from "./communication_room_reads" +export * from "./communication_hidden_rooms" export * from "./contacts" export * from "./contracts" export * from "./contracttypes" diff --git a/backend/db/schema/projects.ts b/backend/db/schema/projects.ts index fdb04539..37b89a28 100644 --- a/backend/db/schema/projects.ts +++ b/backend/db/schema/projects.ts @@ -66,6 +66,8 @@ export const projects = pgTable("projects", { archived: boolean("archived").notNull().default(false), + chatEnabled: boolean("chat_enabled").notNull().default(true), + customerRef: text("customerRef"), updatedAt: timestamp("updated_at", { withTimezone: true }), diff --git a/backend/src/modules/communication-access.ts b/backend/src/modules/communication-access.ts index 2aa72358..3181252b 100644 --- a/backend/src/modules/communication-access.ts +++ b/backend/src/modules/communication-access.ts @@ -15,9 +15,18 @@ export function managesProjects(req: any): boolean { return Boolean(req.user?.is_admin || req.hasPermission?.("organisation.projects.write")) } +export function projectChatSettingError(req: any, body: Record, previous = true) { + if (!Object.prototype.hasOwnProperty.call(body, "chatEnabled")) return null + if (typeof body.chatEnabled !== "boolean") return { statusCode: 400, error: "Projektchat aktiviert muss ein Wahrheitswert sein" } + if (body.chatEnabled !== previous && !managesProjects(req)) { + return { statusCode: 403, error: "Keine Berechtigung zum Ändern des Projektchats" } + } + return null +} + export async function projectChatAccess(db: any, req: any, projectId?: number) { const tenantId = Number(req.user?.tenant_id) - const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false)] + const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false), eq(projects.chatEnabled, true)] if (projectId !== undefined) conditions.push(eq(projects.id, projectId)) const rows = await db.select().from(projects).where(and(...conditions)) if (managesProjects(req)) return rows diff --git a/backend/src/routes/communication.ts b/backend/src/routes/communication.ts index e94ef16a..202154dc 100644 --- a/backend/src/routes/communication.ts +++ b/backend/src/routes/communication.ts @@ -9,6 +9,7 @@ import { communicationRoomMembers, communicationRoomReads, communicationRooms, + communicationHiddenRooms, notificationsItems, projects, } from "../../db/schema" @@ -244,6 +245,28 @@ export default async function communicationRoutes(server: FastifyInstance) { server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" })) + server.get("/communication/chat/room-preferences", async (req: any) => { + const tenantId = await requireTenant(req) + const hidden = await server.db.select({ key: communicationHiddenRooms.roomKey }).from(communicationHiddenRooms) + .where(and(eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id))) + return { hiddenRoomKeys: hidden.map(room => room.key) } + }) + + server.put("/communication/chat/room-preferences", async (req: any, reply) => { + const tenantId = await requireTenant(req) + const { roomKey, hidden } = req.body || {} + if (typeof hidden !== "boolean" || typeof roomKey !== "string" || roomKey.length > 200 + || !/^(?:[a-z0-9._=-]+|collaboration:[a-f0-9-]{36})$/.test(roomKey)) { + return reply.code(400).send({ error: "Ungültige Raumeinstellung" }) + } + const value = { tenantId, userId: req.user.user_id, roomKey } + if (hidden) await server.db.insert(communicationHiddenRooms).values(value).onConflictDoNothing() + else await server.db.delete(communicationHiddenRooms).where(and( + eq(communicationHiddenRooms.tenantId, tenantId), eq(communicationHiddenRooms.userId, req.user.user_id), + eq(communicationHiddenRooms.roomKey, roomKey))) + return { roomKey, hidden } + }) + server.get("/communication/chat/users", async (req: any) => { const tenantId = await requireTenant(req) return { users: (await tenantUsers(tenantId)).map((user) => ({ diff --git a/backend/src/routes/resources/main.ts b/backend/src/routes/resources/main.ts index f38e7d64..d69232d6 100644 --- a/backend/src/routes/resources/main.ts +++ b/backend/src/routes/resources/main.ts @@ -1,4 +1,5 @@ import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service" +import { projectChatSettingError } from "../../modules/communication-access" import { FastifyInstance } from "fastify" import { eq, @@ -942,6 +943,10 @@ export default async function resourceRoutes(server: FastifyInstance) { const table = config.table; let createData: Record = { ...body, tenant: req.user.tenant_id, archived: false }; + if (resource === "projects") { + const error = projectChatSettingError(req, createData) + if (error) return reply.code(error.statusCode).send({ error: error.error }) + } if (resource === "members") { createData = normalizeMemberPayload(createData) @@ -1095,6 +1100,11 @@ export default async function resourceRoutes(server: FastifyInstance) { return reply.code(404).send({ error: "Resource not found" }) } + if (resource === "projects") { + const error = projectChatSettingError(req, body, oldRecord.chatEnabled) + if (error) return reply.code(error.statusCode).send({ error: error.error }) + } + let data: Record = { ...body } delete data.updatedAt delete data.updated_at diff --git a/backend/tests/chatVisibility.test.ts b/backend/tests/chatVisibility.test.ts new file mode 100644 index 00000000..360761a9 --- /dev/null +++ b/backend/tests/chatVisibility.test.ts @@ -0,0 +1,110 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { randomUUID } from "node:crypto" +import { readFile } from "node:fs/promises" +import Fastify from "fastify" +import communicationRoutes from "../src/routes/communication" +import resourceRoutes from "../src/routes/resources/main" +import { projectChatSettingError } from "../src/modules/communication-access" +import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db" + +test("changing the project chat switch requires project management permission and a boolean", () => { + const member = { user: {}, hasPermission: () => false } + const manager = { user: {}, hasPermission: (permission: string) => permission === "organisation.projects.write" } + assert.equal(projectChatSettingError(member, {}), null) + assert.equal(projectChatSettingError(member, { chatEnabled: true }), null) + assert.equal(projectChatSettingError(member, { chatEnabled: false }, false), null) + assert.equal(projectChatSettingError(member, { chatEnabled: false })?.statusCode, 403) + assert.equal(projectChatSettingError(member, { chatEnabled: true }, false)?.statusCode, 403) + assert.equal(projectChatSettingError(manager, { chatEnabled: false }), null) + assert.equal(projectChatSettingError({ user: { is_admin: true } }, { chatEnabled: true }, false), null) + for (const invalid of [null, "false", 0, {}]) assert.equal(projectChatSettingError(manager, { chatEnabled: invalid })?.statusCode, 400) +}) + +test("migration keeps existing and newly created project chats enabled", { skip: !testDatabaseUrl }, async () => { + const fixture = await collaborationDatabase() + try { + await fixture.pool.query("ALTER TABLE projects DROP COLUMN chat_enabled") + await fixture.pool.query("INSERT INTO projects (id,tenant,name) VALUES (1,1,'Existing')") + await fixture.pool.query(await readFile(new URL("../db/migrations/0078_chat_visibility.sql", import.meta.url), "utf8")) + await fixture.pool.query("INSERT INTO projects (id,tenant,name) VALUES (2,1,'New')") + assert.deepEqual((await fixture.pool.query("SELECT chat_enabled FROM projects ORDER BY id")).rows, [{ chat_enabled: true }, { chat_enabled: true }]) + } finally { await fixture.close() } +}) + +test("hidden rooms persist per user and tenant without changing room memberships", { skip: !testDatabaseUrl }, async () => { + const fixture = await collaborationDatabase(["0078_chat_visibility.sql"]), app = Fastify() + const user = randomUUID(), other = randomUUID(), collaborationKey = `collaboration:${randomUUID()}` + try { + await fixture.pool.query("INSERT INTO tenants(id,name) VALUES (1,'A'),(2,'B')") + await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'a@example.test'),($2,'b@example.test')", [user, other]) + await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1),(1,$2)", [user, other]) + app.decorate("db", fixture.db) + app.addHook("preHandler", async (req: any) => { + req.user = { tenant_id: Number(req.headers["x-tenant"] || 1), user_id: req.headers["x-user"] || user } + req.hasPermission = () => false + }) + await app.register(communicationRoutes) + const url = "/communication/chat/room-preferences" + for (const roomKey of ["allgemein", "project_1", collaborationKey, collaborationKey]) { + const result = await app.inject({ method: "PUT", url, payload: { roomKey, hidden: true, userId: other, tenantId: 2 } }) + assert.equal(result.statusCode, 200, result.body) + } + assert.deepEqual((await app.inject(url)).json().hiddenRoomKeys.sort(), ["allgemein", "project_1", collaborationKey].sort()) + assert.deepEqual((await app.inject({ url, headers: { "x-user": other } })).json().hiddenRoomKeys, []) + assert.deepEqual((await app.inject({ url, headers: { "x-tenant": "2" } })).json().hiddenRoomKeys, []) + assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_room_members")).rows[0].count, "0") + await app.inject({ method: "PUT", url, payload: { roomKey: collaborationKey, hidden: false } }) + assert.deepEqual((await app.inject(url)).json().hiddenRoomKeys.sort(), ["allgemein", "project_1"]) + for (const payload of [{ roomKey: "allgemein", hidden: "false" }, { roomKey: "../secret", hidden: true }, { roomKey: "", hidden: true }]) { + assert.equal((await app.inject({ method: "PUT", url, payload })).statusCode, 400) + } + await fixture.pool.query("DELETE FROM auth_tenant_users WHERE tenant_id=1 AND user_id=$1", [user]) + assert.equal((await app.inject(url)).statusCode, 403) + assert.equal((await app.inject({ method: "PUT", url, payload: { roomKey: "allgemein", hidden: false } })).statusCode, 403) + } finally { await app.close(); await fixture.close() } +}) + +test("disabling in the project form blocks list, provision, reads and writes until re-enabled", { skip: !testDatabaseUrl }, async () => { + const fixture = await collaborationDatabase(), app = Fastify() + const user = randomUUID(), profile = randomUUID() + try { + await fixture.pool.query("INSERT INTO tenants(id,name) VALUES (1,'A'),(2,'B')") + await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'a@example.test')", [user]) + await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1)", [user]) + await fixture.pool.query("INSERT INTO auth_profiles(id,tenant_id,user_id) VALUES ($1,1,$2)", [profile, user]) + await fixture.pool.query("INSERT INTO projects(id,tenant,name,profiles) VALUES (1,1,'Project',ARRAY[$1]::uuid[]),(2,2,'Other tenant','{}')", [profile]) + app.decorate("db", fixture.db) + app.addHook("preHandler", async (req: any) => { + req.user = { tenant_id: 1, user_id: user, is_admin: req.headers["x-admin"] === "true" } + req.hasPermission = (permission: string) => permission === "organisation.projects.write" && req.headers["x-manager"] === "true" + }) + await app.register(communicationRoutes) + await app.register(resourceRoutes) + const update = (chatEnabled: unknown, headers = { "x-manager": "true" }) => app.inject({ method: "PUT", url: "/resource/projects/1", headers, payload: { chatEnabled } }) + const provision = await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" }) + assert.equal(provision.statusCode, 200, provision.body) + const roomId = provision.json().id + await fixture.pool.query("INSERT INTO communication_messages(tenant_id,room_id,author_user_id,body) VALUES (1,$1,$2,'Preserved history')", [roomId, user]) + assert.equal((await update(false, { "x-manager": "false" })).statusCode, 403) + assert.equal((await update("false")).statusCode, 400) + assert.equal((await app.inject({ method: "PUT", url: "/resource/projects/2", headers: { "x-manager": "true" }, payload: { chatEnabled: false } })).statusCode, 404) + assert.equal((await update(false)).statusCode, 200) + assert.deepEqual((await app.inject("/communication/chat/project-rooms")).json().rooms, []) + assert.equal((await app.inject({ method: "POST", url: "/communication/chat/project-rooms/1/provision" })).statusCode, 404) + for (const suffix of ["messages", "sync", "members"]) { + assert.equal((await app.inject(`/communication/chat/rooms/project_1/${suffix}`)).statusCode, 404) + assert.equal((await app.inject({ url: `/communication/chat/rooms/project_1/${suffix}`, headers: { "x-admin": "true" } })).statusCode, 404) + } + for (const suffix of ["messages", "read"]) assert.equal((await app.inject({ method: "POST", url: `/communication/chat/rooms/project_1/${suffix}`, payload: { text: "Rejected", messageId: 1 } })).statusCode, 404) + assert.deepEqual((await app.inject("/communication/chat/unread")).json().rooms, {}) + assert.equal((await update(true, { "x-manager": "false" })).statusCode, 403) + assert.equal((await update(true)).statusCode, 200) + const history = await app.inject("/communication/chat/rooms/project_1/messages") + assert.equal(history.statusCode, 200, history.body) + assert.deepEqual(history.json().messages.map(message => message.body), ["Preserved history"]) + assert.equal((await app.inject("/communication/chat/project-rooms")).json().rooms.length, 1) + const create = await app.inject({ method: "POST", url: "/resource/projects", payload: { id: 3, name: "Disabled", projectNumber: "P-3", chatEnabled: false } }) + assert.equal(create.statusCode, 403) + } finally { await app.close(); await fixture.close() } +}) diff --git a/backend/tests/helpers/collaboration-db.ts b/backend/tests/helpers/collaboration-db.ts index 057e4d4c..f7fac4a2 100644 --- a/backend/tests/helpers/collaboration-db.ts +++ b/backend/tests/helpers/collaboration-db.ts @@ -25,7 +25,7 @@ export async function collaborationDatabase(migrations: string[] = []) { let suffix = column.primary ? " PRIMARY KEY" : "" if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()" if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()" - if (column.getSQLType() === "boolean") suffix += " DEFAULT false" + if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}` return `"${column.name}" ${column.getSQLType()}${suffix}` }) await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`) diff --git a/docs/chat-foederation-konzept.md b/docs/chat-foederation-konzept.md index fa7f7008..b9fe05b7 100644 --- a/docs/chat-foederation-konzept.md +++ b/docs/chat-foederation-konzept.md @@ -287,6 +287,8 @@ Unter **Kommunikation → Zusammenarbeit** können interne Teilnehmer ihre eigen Unter **Kommunikation → Chat** erscheinen diese Unterräume ebenfalls in der Raumgruppe **Zusammenarbeit**, einschließlich instanzübergreifender Räume und Ungelesen-Zähler. Einladungen lassen sich dort direkt annehmen oder ablehnen; nach der Annahme öffnet sich der Raum. Nachrichten, Teilnehmer und ausdrücklich freigegebene Projektinformationen werden direkt im normalen Chat angezeigt. **Details und Freigaben** öffnet die bestehende Verwaltungsansicht für den ausgewählten Raum; **Im Chat öffnen** führt zurück. Sichtbar sind ausschließlich Räume mit eigener Teilnahme, keine lediglich verwaltbaren Räume. Interne Chaträume und Unterräume behalten ihre getrennten Berechtigungsprüfungen. +Die Chatansicht nutzt die volle verfügbare Fläche. Über das Augensymbol lassen sich beliebige Räume persönlich ausblenden und unter **Ausgeblendete Räume** wieder einblenden. Diese Auswahl wird pro Benutzer und Mandant gespeichert und bleibt auf anderen Geräten erhalten; Teilnahme, Nachrichten und Benachrichtigungseinstellungen werden dadurch nicht geändert. Unter **Projekt → Bearbeiten → Projektchat aktiviert** können Projektverantwortliche den internen Projektchat für alle Teilnehmer deaktivieren. Er verschwindet aus der Chatliste und ist auch über direkte Aufrufe nicht mehr les- oder beschreibbar. Beim erneuten Aktivieren bleibt der bisherige Verlauf erhalten. Bestehende und neue Projekte starten mit aktiviertem Projektchat. Separat freigegebene Unterräume bleiben unabhängig davon verwaltbar. + Einladungen unterstützen eigene Mitarbeiter, persönliche FEDEO-Kontaktadressen und Gäste ohne FEDEO-Konto. Eine Kontaktadresse bezeichnet immer genau einen Benutzer in einem Heimatmandanten. Sie lässt sich in der Zusammenarbeit kopieren. Kontakte anderer Mandanten derselben Instanz benötigen keinen Server-Server-Transport. Fremde Instanzen müssen auf beiden Seiten im jeweiligen Mandanten unter **Partnerinstanzen** freigegeben sein. Beim Empfänger erscheint ein Angebot, das ausdrücklich angenommen oder abgelehnt wird. Gäste erhalten ausschließlich einen Projekt-Unterraum. Der Link öffnet `/guest`; ein achtstelliger, zehn Minuten gültiger Einmalcode wird an die eingeladene Adresse geschickt. Nach fünf falschen Versuchen ist der Code gesperrt. Die eigene Gastsitzung gilt zwölf Stunden und wird nicht als reguläres FEDEO-Token akzeptiert. Für spätere Anmeldungen bietet die Gastoberfläche eine als Lesezeichen speicherbare Adresse mit öffentlicher Workspace-ID an. Ohne gewähltes Ablaufdatum gilt die Teilnahme eines Gasts 30 Tage. Der Einladungslink allein erlaubt keinen Inhaltszugriff. @@ -299,7 +301,7 @@ Bei einem vorübergehend nicht erreichbaren Eigentümer sind bereits geladene Na ### Einrichtung -1. Vor dem Backendstart die additiven Migrationen `0075_project_collaboration.sql` und `0076_collaboration_federation.sql` über den bestehenden Migrationslauf einspielen. Der Selfhost-Container führt standardmäßig `npm run migrate` aus. Bestehende interne Chats bleiben getrennt; kein vorhandener Chat wird automatisch freigegeben. +1. Vor dem Backendstart die additiven Migrationen `0075_project_collaboration.sql`, `0076_collaboration_federation.sql` und `0078_chat_visibility.sql` über den bestehenden Migrationslauf einspielen. Der Selfhost-Container führt standardmäßig `npm run migrate` aus. Bestehende interne Chats bleiben getrennt; kein vorhandener Chat wird automatisch freigegeben. 2. Die vorhandene `API_BASE_URL` wird als Föderationsadresse verwendet, einschließlich des Reverse-Proxy-Präfixes, etwa `https://fedeo.example/backend`. Gastlinks werden aus deren Ursprung `https://fedeo.example` abgeleitet. Zusätzliche URL-Variablen sind im Standardbetrieb nicht nötig. Für Gastcodes und optional versendete Einladungs-E-Mails muss der bestehende SMTP-Versand funktionieren. Den vorhandenen `ENCRYPTION_KEY` beibehalten; er muss ein stabiler 64-stelliger Hex-Schlüssel sein. 3. Der Standard ist jetzt `FEDEO_FEDERATION_ENABLED=auto`, auch wenn die Variable nicht gesetzt ist. Mit gültiger HTTPS-API-Adresse auf Port 443 und gültigem Verschlüsselungsschlüssel wird Föderation automatisch bereitgestellt. Lokale HTTP-Entwicklungsumgebungen oder unvollständige Konfigurationen bleiben ohne Föderation startfähig; die Partnerverwaltung nennt den fehlenden Konfigurationswert. `false` schaltet Föderation ausdrücklich ab. `true` erzwingt die Aktivierung und meldet eine ungültige Konfiguration bereits beim Start. Nur bei abweichender Infrastruktur `FEDEO_FEDERATION_URL` oder `FEDEO_PUBLIC_URL` setzen; beide überschreiben die automatisch abgeleiteten Werte. Ein separates Föderationsgateway verändert die Gast-Webadresse nicht automatisch. 4. Der Proxy muss `GET /.well-known/fedeo-federation` und `POST /federation/v1/rpc` durchreichen und darf JSON-Body oder Signaturheader nicht verändern. Externe Ziele benötigen öffentlich auflösbare HTTPS-Adressen auf Port 443. Private/reservierte IPv4- und IPv6-Ziele sowie Weiterleitungen werden abgewiesen; die geprüfte DNS-Adresse wird für die TLS-Verbindung festgehalten. diff --git a/frontend/pages/communication/chat.vue b/frontend/pages/communication/chat.vue index d6775188..dd3e69d1 100644 --- a/frontend/pages/communication/chat.vue +++ b/frontend/pages/communication/chat.vue @@ -12,6 +12,8 @@ const requestedRoom = () => typeof route.query.room === 'string' ? route.query.r const activeRoomKey = ref(requestedRoom()) const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false) const showRoomForm = ref(false), messagesViewport = ref(null) +const hiddenRoomKeys = ref([]), showHiddenRooms = ref(false), preferenceBusy = ref(''), preferenceError = ref('') +const preferencesReady = ref(false) const roomForm = reactive({ name: '', key: '', topic: '' }) const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`) const hasSession = computed(() => !!auth.user && !!auth.activeTenant) @@ -38,10 +40,12 @@ const decoratedRooms = computed(() => [ ...collaborationRooms.value.map(room => ({ ...room, key: collaborationKey(room.id), source: 'collaboration', exists: true, group: 'Zusammenarbeit', icon: 'i-heroicons-user-group', unread: room.unread || 0 })) ]) +const visibleRooms = computed(() => decoratedRooms.value.filter(room => !hiddenRoomKeys.value.includes(room.key))) +const hiddenRooms = computed(() => decoratedRooms.value.filter(room => hiddenRoomKeys.value.includes(room.key))) const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit'] - .map(label => ({ label, rooms: decoratedRooms.value.filter(room => room.group === label) })) + .map(label => ({ label, rooms: visibleRooms.value.filter(room => room.group === label) })) .filter(group => group.rooms.length)) -const activeRoom = computed(() => decoratedRooms.value.find(room => room.key === activeRoomKey.value) || null) +const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null) const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration') const isInternal = computed(() => activeRoom.value?.source === 'internal') @@ -96,6 +100,40 @@ async function refreshLists() { await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)]) } +async function loadRoomPreferences(scope = scopeVersion) { + if (!hasSession.value || !currentScope(scope)) return + try { + const result = await $api('/api/communication/chat/room-preferences') + if (!currentScope(scope)) return + hiddenRoomKeys.value = result.hiddenRoomKeys || [] + preferencesReady.value = true; preferenceError.value = '' + } catch (error) { + if (currentScope(scope)) preferenceError.value = errorMessage(error) + } +} +async function setRoomHidden(room, hidden) { + if (preferenceBusy.value || !preferencesReady.value) return + const scope = scopeVersion + preferenceBusy.value = room.key + try { + await $api('/api/communication/chat/room-preferences', { method: 'PUT', body: { roomKey: room.key, hidden } }) + if (!currentScope(scope)) return + hiddenRoomKeys.value = hidden + ? [...new Set([...hiddenRoomKeys.value, room.key])] + : hiddenRoomKeys.value.filter(key => key !== room.key) + if (hidden && activeRoomKey.value === room.key) { + ++roomVersion; activeRoomKey.value = ''; clearMessages() + const next = visibleRooms.value.find(candidate => candidate.exists) + if (next) await selectRoom(next) + else await router.replace({ query: { ...route.query, room: undefined } }) + } else if (!hidden) { + const restored = visibleRooms.value.find(candidate => candidate.key === room.key) + if (restored) await selectRoom(restored) + } + } catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) } + finally { if (currentScope(scope)) preferenceBusy.value = '' } +} + async function markRead(request) { if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return const messageId = Number(messages.value.at(-1)?.id || 0) @@ -119,6 +157,7 @@ async function loadActiveRoom(request = selection()) { } finally { if (currentSelection(request)) roomLoading.value = false } } async function selectRoom(room) { + if (hiddenRoomKeys.value.includes(room.key)) return ++roomVersion; activeRoomKey.value = room.key; clearMessages() let request = selection() try { @@ -199,18 +238,22 @@ async function initialize() { ++roomVersion; clearMessages() baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = [] unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false + hiddenRoomKeys.value = []; showHiddenRooms.value = false; preferenceBusy.value = ''; preferenceError.value = ''; preferencesReady.value = false showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' }) activeRoomKey.value = requestedRoom(); loading.value = true - await refreshLists() + await Promise.all([refreshLists(), loadRoomPreferences(scope)]) if (!currentScope(scope)) return loading.value = false - const room = activeRoom.value || (activeRoomKey.value === 'allgemein' ? decoratedRooms.value[0] : null) + const room = activeRoom.value || (activeRoomKey.value === 'allgemein' ? visibleRooms.value.find(room => room.exists) : null) if (room) await selectRoom(room) } watch(session, () => { if (mounted) void initialize() }, { flush: 'sync' }) +watch(() => activeRoom.value?.key, (key, previous) => { + if (!key && previous && !loading.value) { ++roomVersion; clearMessages() } +}) watch(() => route.query.room, () => { if (!mounted || loading.value || requestedRoom() === activeRoomKey.value) return - const room = decoratedRooms.value.find(room => room.key === requestedRoom()) + const room = visibleRooms.value.find(room => room.key === requestedRoom()) if (room) void selectRoom(room) else { ++roomVersion; activeRoomKey.value = requestedRoom(); clearMessages() } }) @@ -223,8 +266,8 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt