24 Commits

Author SHA1 Message Date
e7d6c482ad Merge pull request 'dev' (#239) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m13s
Reviewed-on: #239
2026-10-02 15:17:16 +00:00
ed305f656c Merge pull request 'dev' (#238) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 57s
Build and Release FEDEO / build-and-release (push) Successful in 6m14s
Reviewed-on: #238
2026-10-02 14:18:01 +00:00
30e932a874 Merge pull request 'dev' (#237) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m16s
Reviewed-on: #237
2026-10-02 12:51:46 +00:00
0a74e07288 Merge pull request 'fix(scanner): accept Epson empty write probes' (#234) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m8s
Reviewed-on: #234
2026-10-01 15:36:56 +00:00
4c3de8c942 Merge pull request 'dev' (#233) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 58s
Build and Release FEDEO / build-and-release (push) Successful in 6m11s
Reviewed-on: #233
2026-09-30 17:23:15 +00:00
49d44d9701 Merge pull request 'dev' (#232) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m18s
Reviewed-on: #232
2026-09-30 15:43:45 +00:00
96a7acff33 Merge pull request 'feat(selfhost): show sanitized update logs in admin UI' (#231) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 47s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #231
2026-09-25 17:22:30 +00:00
65588686c7 Merge pull request 'fix(documents): normalize units when selecting and loading rows' (#230) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 48s
Build and Release FEDEO / build-and-release (push) Successful in 6m3s
Reviewed-on: #230
2026-09-25 09:54:08 +00:00
fcf9cc3fc9 Merge pull request 'dev' (#229) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 49s
Build and Release FEDEO / build-and-release (push) Successful in 6m6s
Reviewed-on: #229
2026-09-25 09:24:39 +00:00
20091143d2 Merge pull request 'dev' (#228) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 45s
Build and Release FEDEO / build-and-release (push) Successful in 6m5s
Reviewed-on: #228
2026-09-23 17:20:58 +00:00
f708d0a2b2 Merge pull request 'dev' (#227) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 44s
Build and Release FEDEO / build-and-release (push) Successful in 6m7s
Reviewed-on: #227
2026-09-23 16:15:44 +00:00
7e9f4409f6 Merge pull request 'dev' (#226) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 33s
Build and Release FEDEO / build-and-release (push) Successful in 6m10s
Reviewed-on: #226
2026-09-23 14:57:40 +00:00
2e63af1aa2 Merge pull request 'fix(release): stream large migration checksums from git' (#225) from flfeders/codex/release-migration-checksums into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 25s
Build and Release FEDEO / build-and-release (push) Successful in 5m59s
Reviewed-on: #225
2026-09-23 14:09:57 +00:00
5ebd2d508c Merge pull request 'fix(release): exclude legacy main history from commit checks' (#224) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Successful in 23s
Build and Release FEDEO / build-and-release (push) Failing after 5m57s
Reviewed-on: #224
2026-09-23 13:03:36 +00:00
9128fbb165 Merge pull request 'fix(ci): build dev images without repository write access' (#221) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 26s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #221
2026-09-20 16:25:38 +00:00
8473221c13 Merge pull request '3.0.0' (#220) from dev into main
Some checks failed
Build and Release FEDEO / verify (push) Failing after 22s
Build and Release FEDEO / build-and-release (push) Has been skipped
Reviewed-on: #220
2026-09-20 16:10:46 +00:00
e7554fa2cc .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:42:28 +00:00
7c1fabf58a .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-22 17:40:58 +00:00
1203b6cbd1 .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 16s
2026-01-15 11:39:03 +00:00
525f2906fb .gitea/ISSUE_TEMPLATE/feature_request.md aktualisiert
Some checks failed
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2026-01-15 11:38:50 +00:00
b105382abf .gitea/ISSUE_TEMPLATE/bug_report.md aktualisiert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 16s
Build and Push Docker Images / build-frontend (push) Successful in 15s
2026-01-15 11:38:00 +00:00
b1cdec7d17 Merge pull request 'Added feature request template' (#62) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #62
2026-01-15 11:31:57 +00:00
f1d512b2e5 Merge pull request 'dev' (#61) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 17s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #61
2026-01-15 11:29:15 +00:00
db21b43120 Merge pull request 'dev' (#40) from dev into main
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 18s
Build and Push Docker Images / build-frontend (push) Successful in 16s
Reviewed-on: #40
2026-01-08 22:21:06 +00:00
19 changed files with 200 additions and 847 deletions

View File

@@ -2,37 +2,18 @@
name: 🐛 Bug Report name: 🐛 Bug Report
about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern. about: Erstelle einen Bericht, um uns zu helfen, das Projekt zu verbessern.
title: '[BUG] ' title: '[BUG] '
labels: bug labels: Problem
assignees: '' assignees: ''
--- ---
**Beschreibung** **Beschreibung**
Eine klare und prägnante Beschreibung des Fehlers.
**Reproduktion** **Reproduktion**
Schritte, um den Fehler zu reproduzieren:
Entweder:
1. Gehe zu '...'
2. Klicke auf '...'
3. Scrolle runter zu '...'
4. Siehe Fehler
Oder Link zur Seite
**Erwartetes Verhalten**
Eine klare Beschreibung dessen, was du erwartet hast.
**Screenshots** **Screenshots**
Falls zutreffend, füge hier Screenshots oder Gifs hinzu, um das Problem zu verdeutlichen.
**Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.** **Achtung: Achte bitte auf Datenschutz deiner Daten sowie der Daten deiner Kunden. Sollten ein Screenshot nur mit Daten möglich sein, schwärze diese bitte vor dem Upload.**
**Umgebung:**
- Betriebssystem: [z.B. Windows, macOS, Linux]
- Browser / Version (falls relevant): [z.B. Chrome 120]
- Projekt-Version: [z.B. v1.0.2]
**Zusätzlicher Kontext**
Füge hier alle anderen Informationen zum Problem hinzu.

View File

@@ -2,19 +2,16 @@
name: ✨ Feature Request name: ✨ Feature Request
about: Schlage eine Idee für dieses Projekt vor. about: Schlage eine Idee für dieses Projekt vor.
title: '[FEATURE] ' title: '[FEATURE] '
labels: enhancement labels: Funktionswunsch
assignees: '' assignees: ''
--- ---
**Ist dein Feature-Wunsch mit einem Problem verbunden?** **Ist dein Feature-Wunsch mit einem Problem verbunden?**
Eine klare Beschreibung des Problems (z.B. "Ich bin immer genervt, wenn...").
**Lösungsvorschlag** **Lösungsvorschlag**
Eine klare Beschreibung dessen, was du dir wünschst und wie es funktionieren soll.
**Alternativen** **Alternativen**
Hast du über alternative Lösungen oder Workarounds nachgedacht?
**Zusätzlicher Kontext**
Hier ist Platz für weitere Informationen, Skizzen oder Beispiele von anderen Tools.

View File

@@ -1,8 +0,0 @@
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")
);

View File

@@ -526,13 +526,6 @@
"when": 1790899200000, "when": 1790899200000,
"tag": "0077_scanner_inbox_preparation", "tag": "0077_scanner_inbox_preparation",
"breakpoints": true "breakpoints": true
},
{
"idx": 75,
"version": "7",
"when": 1790985600000,
"tag": "0078_chat_visibility",
"breakpoints": true
} }
] ]
} }

View File

@@ -1,10 +0,0 @@
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] }) }))

View File

@@ -19,7 +19,6 @@ export * from "./communication_rooms"
export * from "./communication_room_members" export * from "./communication_room_members"
export * from "./communication_messages" export * from "./communication_messages"
export * from "./communication_room_reads" export * from "./communication_room_reads"
export * from "./communication_hidden_rooms"
export * from "./contacts" export * from "./contacts"
export * from "./contracts" export * from "./contracts"
export * from "./contracttypes" export * from "./contracttypes"

View File

@@ -66,8 +66,6 @@ export const projects = pgTable("projects", {
archived: boolean("archived").notNull().default(false), archived: boolean("archived").notNull().default(false),
chatEnabled: boolean("chat_enabled").notNull().default(true),
customerRef: text("customerRef"), customerRef: text("customerRef"),
updatedAt: timestamp("updated_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }),

View File

@@ -15,18 +15,9 @@ export function managesProjects(req: any): boolean {
return Boolean(req.user?.is_admin || req.hasPermission?.("organisation.projects.write")) return Boolean(req.user?.is_admin || req.hasPermission?.("organisation.projects.write"))
} }
export function projectChatSettingError(req: any, body: Record<string, unknown>, 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) { export async function projectChatAccess(db: any, req: any, projectId?: number) {
const tenantId = Number(req.user?.tenant_id) const tenantId = Number(req.user?.tenant_id)
const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false), eq(projects.chatEnabled, true)] const conditions = [eq(projects.tenant, tenantId), eq(projects.archived, false)]
if (projectId !== undefined) conditions.push(eq(projects.id, projectId)) if (projectId !== undefined) conditions.push(eq(projects.id, projectId))
const rows = await db.select().from(projects).where(and(...conditions)) const rows = await db.select().from(projects).where(and(...conditions))
if (managesProjects(req)) return rows if (managesProjects(req)) return rows

View File

@@ -9,7 +9,6 @@ import {
communicationRoomMembers, communicationRoomMembers,
communicationRoomReads, communicationRoomReads,
communicationRooms, communicationRooms,
communicationHiddenRooms,
notificationsItems, notificationsItems,
projects, projects,
} from "../../db/schema" } from "../../db/schema"
@@ -245,28 +244,6 @@ export default async function communicationRoutes(server: FastifyInstance) {
server.get("/communication/chat/status", async () => ({ ready: true, provider: "fedeo" })) 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) => { server.get("/communication/chat/users", async (req: any) => {
const tenantId = await requireTenant(req) const tenantId = await requireTenant(req)
return { users: (await tenantUsers(tenantId)).map((user) => ({ return { users: (await tenantUsers(tenantId)).map((user) => ({

View File

@@ -1,5 +1,4 @@
import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service" import { validateCostCentreParent } from "../../modules/cost-centre-hierarchy.service"
import { projectChatSettingError } from "../../modules/communication-access"
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { import {
eq, eq,
@@ -943,10 +942,6 @@ export default async function resourceRoutes(server: FastifyInstance) {
const table = config.table; const table = config.table;
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false }; let createData: Record<string, any> = { ...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") { if (resource === "members") {
createData = normalizeMemberPayload(createData) createData = normalizeMemberPayload(createData)
@@ -1100,11 +1095,6 @@ export default async function resourceRoutes(server: FastifyInstance) {
return reply.code(404).send({ error: "Resource not found" }) 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<string, any> = { ...body } let data: Record<string, any> = { ...body }
delete data.updatedAt delete data.updatedAt
delete data.updated_at delete data.updated_at

View File

@@ -1,110 +0,0 @@
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() }
})

View File

@@ -25,7 +25,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
let suffix = column.primary ? " PRIMARY KEY" : "" let suffix = column.primary ? " PRIMARY KEY" : ""
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()" if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()" if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}` if (column.getSQLType() === "boolean") suffix += " DEFAULT false"
return `"${column.name}" ${column.getSQLType()}${suffix}` return `"${column.name}" ${column.getSQLType()}${suffix}`
}) })
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`) await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(",")})`)

View File

@@ -285,10 +285,6 @@ Als erster nutzbarer Meilenstein wird **ein abgegrenzter Projekt-Unterraum mit e
Unter **Kommunikation → Zusammenarbeit** können interne Teilnehmer ihre eigenen Unterräume und Einladungen öffnen. Projektverantwortliche mit `organisation.projects.write` beziehungsweise Administratoren können Unterräume verwalten und freie Partnerchats anlegen. Der Projektkopf verlinkt ebenfalls auf die Zusammenarbeit. Die Verwaltung eines Raums berechtigt weiterhin nicht zum Lesen seiner Nachrichten; nur der Ersteller wird bei der Anlage automatisch Teilnehmer. Unter **Kommunikation → Zusammenarbeit** können interne Teilnehmer ihre eigenen Unterräume und Einladungen öffnen. Projektverantwortliche mit `organisation.projects.write` beziehungsweise Administratoren können Unterräume verwalten und freie Partnerchats anlegen. Der Projektkopf verlinkt ebenfalls auf die Zusammenarbeit. Die Verwaltung eines Raums berechtigt weiterhin nicht zum Lesen seiner Nachrichten; nur der Ersteller wird bei der Anlage automatisch Teilnehmer.
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. 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. 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.
@@ -301,7 +297,7 @@ Bei einem vorübergehend nicht erreichbaren Eigentümer sind bereits geladene Na
### Einrichtung ### Einrichtung
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. 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.
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. 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. 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 <API-Adresse>/.well-known/fedeo-federation` und `POST <API-Adresse>/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. 4. Der Proxy muss `GET <API-Adresse>/.well-known/fedeo-federation` und `POST <API-Adresse>/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.

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } }) const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' } })
const emit = defineEmits(['changed', 'unauthorized']) const emit = defineEmits(['changed', 'unauthorized'])
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), error = ref('') const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), error = ref('')
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null) const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
@@ -95,9 +95,9 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
</script> </script>
<template> <template>
<section class="flex min-w-0 flex-col bg-white dark:bg-gray-900" :class="embedded ? 'min-h-0 overflow-hidden' : 'min-h-[32rem] rounded-xl border border-gray-200 dark:border-gray-700'"> <section class="flex min-h-[32rem] min-w-0 flex-col rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-900">
<header class="border-b border-gray-200 p-4 dark:border-gray-700"> <header class="border-b border-gray-200 p-4 dark:border-gray-700">
<div class="flex flex-wrap items-center justify-between gap-2"><h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2><slot name="actions" /></div> <h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2>
<p v-if="room?.topic" class="mt-1 text-sm text-gray-500">{{ room.topic }}</p> <p v-if="room?.topic" class="mt-1 text-sm text-gray-500">{{ room.topic }}</p>
<p v-if="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p> <p v-if="members.length" class="mt-2 text-xs text-gray-500">{{ members.map(m => `${m.name}${m.kind === 'guest' ? ' (Gast)' : ''}`).join(' · ') }}</p>
</header> </header>
@@ -110,7 +110,7 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
<p v-if="room.overview.date">Termin: {{ room.overview.date }}</p> <p v-if="room.overview.date">Termin: {{ room.overview.date }}</p>
<p v-if="room.overview.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p> <p v-if="room.overview.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
</div> </div>
<div ref="viewport" class="flex-1 space-y-3 overflow-y-auto p-4" :class="embedded ? 'min-h-0' : 'max-h-[60vh] min-h-[18rem]'" aria-live="polite"> <div ref="viewport" class="max-h-[60vh] min-h-[18rem] flex-1 space-y-3 overflow-y-auto p-4" aria-live="polite">
<p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p> <p v-if="loading" class="text-sm text-gray-500">Nachrichten werden geladen …</p>
<button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button> <button v-if="hasMore" class="block w-full text-center text-sm text-green-700 underline" :disabled="olderLoading" @click="older">{{ olderLoading ? 'Wird geladen …' : 'Ältere Nachrichten laden' }}</button>
<p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p> <p v-if="room && !loading && !messages.length" class="py-12 text-center text-sm text-gray-500">Hier beginnt eure Unterhaltung. Du siehst Nachrichten ab deinem Beitritt.</p>

View File

@@ -1,274 +1,219 @@
<script setup> <script setup>
const toast = useToast() const toast = useToast()
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const auth = useAuthStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([]) const baseRooms = ref([])
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('') const projectRooms = ref([])
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref('') const directRooms = ref([])
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein' const unreadRooms = ref({})
const activeRoomKey = ref(requestedRoom()) const messages = ref([])
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false) const members = ref([])
const showRoomForm = ref(false), messagesViewport = ref(null) const draft = ref("")
const hiddenRoomKeys = ref([]), showHiddenRooms = ref(false), preferenceBusy = ref(''), preferenceError = ref('') const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
const preferencesReady = ref(false) const loading = ref(true)
const roomForm = reactive({ name: '', key: '', topic: '' }) const roomLoading = ref(false)
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`) const sending = ref(false)
const hasSession = computed(() => !!auth.user && !!auth.activeTenant) const creating = ref(false)
let pollTimer, listTimer, pollBusy = false, mounted = false, disposed = false const showRoomForm = ref(false)
let scopeVersion = 0, roomVersion = 0, collaborationRequest = 0, nativeListRequest = 0, syncAfterId = 0 const messagesViewport = ref(null)
const currentScope = version => !disposed && version === scopeVersion const roomForm = reactive({ name: "", key: "", topic: "" })
const selection = () => ({ scope: scopeVersion, version: roomVersion, key: activeRoomKey.value }) let pollTimer = null
const currentSelection = request => currentScope(request.scope) && request.version === roomVersion && request.key === activeRoomKey.value let pollBusy = false
const errorMessage = error => error?.data?.message || error?.data?.error || 'Die Anfrage konnte nicht ausgeführt werden.' let syncAfterId = 0
// Colons cannot occur in normalized internal room keys, so both room types stay unambiguous.
const collaborationKey = id => `collaboration:${id}` const normalizeRoomKey = (value) => String(value || "")
const nativeEndpoint = (key, suffix = '') => `/api/communication/chat/rooms/${encodeURIComponent(key)}${suffix}` .toLowerCase()
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/ß/g, "ss")
.replace(/[^a-z0-9._=-]+/g, "_")
.replace(/_+/g, "_")
.replace(/^[._=-]+|[._=-]+$/g, "")
const normalizeRoomKey = value => String(value || '')
.toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss')
.replace(/[^a-z0-9._=-]+/g, '_').replace(/_+/g, '_').replace(/^[._=-]+|[._=-]+$/g, '')
const roomKeyPreview = computed(() => normalizeRoomKey(roomForm.key || roomForm.name)) const roomKeyPreview = computed(() => normalizeRoomKey(roomForm.key || roomForm.name))
const decoratedRooms = computed(() => [ const decoratedRooms = computed(() => [
...[ ...baseRooms.value.map((room) => ({ ...room, group: "Räume", icon: "i-heroicons-chat-bubble-left-right" })),
...baseRooms.value.map(room => ({ ...room, group: 'Räume', icon: 'i-heroicons-chat-bubble-left-right' })), ...projectRooms.value.map((room) => ({ ...room, group: "Projekte", icon: "i-heroicons-briefcase" })),
...projectRooms.value.map(room => ({ ...room, group: 'Projekte', icon: 'i-heroicons-briefcase' })), ...directRooms.value.map((room) => ({ ...room, group: "Direktnachrichten", icon: "i-heroicons-user-circle" }))
...directRooms.value.map(room => ({ ...room, group: 'Direktnachrichten', icon: 'i-heroicons-user-circle' })) ].map((room) => ({ ...room, unread: unreadRooms.value[room.key]?.count || 0 })))
].map(room => ({ ...room, source: 'internal', unread: unreadRooms.value[room.key]?.count || 0 })), const groupedRooms = computed(() => ["Räume", "Projekte", "Direktnachrichten"]
...collaborationRooms.value.map(room => ({ ...room, key: collaborationKey(room.id), source: 'collaboration', exists: true, .map((label) => ({ label, rooms: decoratedRooms.value.filter((room) => room.group === label) }))
group: 'Zusammenarbeit', icon: 'i-heroicons-user-group', unread: room.unread || 0 })) .filter((group) => group.rooms.length))
]) const activeRoom = computed(() => decoratedRooms.value.find((room) => room.key === activeRoomKey.value) || null)
const visibleRooms = computed(() => decoratedRooms.value.filter(room => !hiddenRoomKeys.value.includes(room.key))) const roomEndpoint = computed(() => `/api/communication/chat/rooms/${encodeURIComponent(activeRoomKey.value)}`)
const hiddenRooms = computed(() => decoratedRooms.value.filter(room => hiddenRoomKeys.value.includes(room.key)))
const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit']
.map(label => ({ label, rooms: visibleRooms.value.filter(room => room.group === label) }))
.filter(group => group.rooms.length))
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')
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; syncAfterId = 0; roomLoading.value = false; sending.value = false } const mergeMessages = (incoming) => {
function mergeMessages(incoming) { const byId = new Map(messages.value.map((message) => [message.id, message]))
const byId = new Map(messages.value.map(message => [message.id, message]))
for (const message of incoming || []) byId.set(message.id, message) for (const message of incoming || []) byId.set(message.id, message)
messages.value = [...byId.values()].sort((a, b) => Number(a.id) - Number(b.id)) messages.value = Array.from(byId.values()).sort((a, b) => Number(a.id) - Number(b.id))
syncAfterId = Number(messages.value.at(-1)?.id || 0) syncAfterId = Number(messages.value.at(-1)?.id || syncAfterId)
} }
const scrollToBottom = async request => {
const scrollToBottom = async () => {
await nextTick() await nextTick()
if (currentSelection(request) && messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight if (messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight
} }
async function loadUnread(scope = scopeVersion) { const loadUnread = async () => {
if (!hasSession.value || !currentScope(scope)) return
try { try {
const result = await $api('/api/communication/chat/unread') unreadRooms.value = (await $api("/api/communication/chat/unread")).rooms || {}
if (currentScope(scope)) unreadRooms.value = result.rooms || {} } catch {
} catch { /* A count failure must not interrupt an open conversation. */ } unreadRooms.value = {}
}
async function loadRoomLists(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
const request = ++nativeListRequest
try {
const [rooms, projects, directs] = await Promise.all([
$api('/api/communication/chat/rooms'), $api('/api/communication/chat/project-rooms'), $api('/api/communication/chat/direct-rooms')
])
if (!currentScope(scope) || request !== nativeListRequest) return
baseRooms.value = rooms.rooms || []; projectRooms.value = projects.rooms || []; directRooms.value = directs.rooms || []
listError.value = ''
} catch (error) { if (currentScope(scope) && request === nativeListRequest) listError.value = errorMessage(error) }
}
async function loadCollaboration(scope = scopeVersion) {
if (!hasSession.value || !currentScope(scope)) return
const request = ++collaborationRequest
try {
const result = await $api('/api/collaboration')
if (!currentScope(scope) || request !== collaborationRequest) return
// Managed rooms alone never grant chat access. Only explicit memberships enter this list.
collaborationRooms.value = result.rooms || []; invitations.value = result.invitations || []
collaborationError.value = ''
} catch (error) {
if (!currentScope(scope) || request !== collaborationRequest) return
collaborationError.value = errorMessage(error)
if ([401, 403].includes(error?.statusCode || error?.status)) { collaborationRooms.value = []; invitations.value = [] }
} }
} }
async function refreshLists() {
const scope = scopeVersion const loadRoomLists = async () => {
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)]) const [rooms, projects, directs] = await Promise.all([
$api("/api/communication/chat/rooms"),
$api("/api/communication/chat/project-rooms"),
$api("/api/communication/chat/direct-rooms")
])
baseRooms.value = rooms.rooms || []
projectRooms.value = projects.rooms || []
directRooms.value = directs.rooms || []
} }
async function loadRoomPreferences(scope = scopeVersion) { const markRead = async () => {
if (!hasSession.value || !currentScope(scope)) return if (!activeRoom.value?.exists) return
try { try {
const result = await $api('/api/communication/chat/room-preferences') await $api(`${roomEndpoint.value}/read`, {
if (!currentScope(scope)) return method: "POST",
hiddenRoomKeys.value = result.hiddenRoomKeys || [] body: { messageId: Number(messages.value.at(-1)?.id || 0) }
preferencesReady.value = true; preferenceError.value = '' })
} catch (error) { unreadRooms.value = { ...unreadRooms.value, [activeRoomKey.value]: { count: 0, mentions: 0 } }
if (currentScope(scope)) preferenceError.value = errorMessage(error) } catch {
// Der Lesestatus darf die Unterhaltung nicht blockieren.
} }
} }
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) { const loadActiveRoom = async () => {
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return if (!activeRoom.value?.exists) return
const messageId = Number(messages.value.at(-1)?.id || 0)
try {
await $api(nativeEndpoint(request.key, '/read'), { method: 'POST', body: { messageId } })
if (currentSelection(request)) unreadRooms.value = { ...unreadRooms.value, [request.key]: { count: 0, mentions: 0 } }
} catch { /* Read receipts must not interrupt the conversation. */ }
}
async function loadActiveRoom(request = selection()) {
if (!currentSelection(request) || !isInternal.value || !activeRoom.value?.exists) return
roomLoading.value = true roomLoading.value = true
syncAfterId = 0
try { try {
const [result, people] = await Promise.all([ const [messageResult, memberResult] = await Promise.all([
$api(nativeEndpoint(request.key, '/messages')), $api(nativeEndpoint(request.key, '/members')) $api(`${roomEndpoint.value}/messages`),
$api(`${roomEndpoint.value}/members`)
]) ])
if (!currentSelection(request)) return messages.value = []
messages.value = []; mergeMessages(result.messages); members.value = people.members || [] mergeMessages(messageResult.messages || [])
await markRead(request); await scrollToBottom(request) members.value = memberResult.members || []
await markRead()
await scrollToBottom()
} catch (error) { } catch (error) {
if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' }) toast.add({ title: error?.data?.error || "Chatraum konnte nicht geladen werden", color: "error" })
} finally { if (currentSelection(request)) roomLoading.value = false } } finally {
roomLoading.value = false
}
} }
async function selectRoom(room) {
if (hiddenRoomKeys.value.includes(room.key)) return const provisionRoom = async (room) => {
++roomVersion; activeRoomKey.value = room.key; clearMessages() const endpoint = room.type === "project"
let request = selection() ? `/api/communication/chat/project-rooms/${room.projectId}/provision`
: `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`
const created = await $api(endpoint, { method: "POST" })
await loadRoomLists()
activeRoomKey.value = created.key
await router.replace({ query: { ...route.query, room: created.key } })
}
const selectRoom = async (room) => {
try { try {
if (room.source !== 'collaboration' && !room.exists) { if (!room.exists) await provisionRoom(room)
const endpoint = room.type === 'project' else {
? `/api/communication/chat/project-rooms/${room.projectId}/provision` activeRoomKey.value = room.key
: `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision` await router.replace({ query: { ...route.query, room: room.key } })
const created = await $api(endpoint, { method: 'POST' })
if (!currentSelection(request)) return
await loadRoomLists(request.scope)
if (!currentSelection(request)) return
activeRoomKey.value = created.key; request = selection()
} }
await router.replace({ query: { ...route.query, room: request.key } }) messages.value = []
if (currentSelection(request)) await loadActiveRoom(request) members.value = []
} catch (error) { if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' }) } await loadActiveRoom()
} catch (error) {
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geöffnet werden", color: "error" })
}
} }
async function createRoom() {
if (!roomForm.name.trim() || creating.value) return const createRoom = async () => {
const scope = scopeVersion if (!roomForm.name.trim()) return
creating.value = true creating.value = true
try { try {
const created = await $api('/api/communication/chat/rooms', { const created = await $api("/api/communication/chat/rooms", {
method: 'POST', body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic } method: "POST",
body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
}) })
if (!currentScope(scope)) return Object.assign(roomForm, { name: "", key: "", topic: "" })
Object.assign(roomForm, { name: '', key: '', topic: '' }); showRoomForm.value = false showRoomForm.value = false
await loadRoomLists(scope) await loadRoomLists()
if (currentScope(scope)) await selectRoom(created) await selectRoom(created)
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
finally { if (currentScope(scope)) creating.value = false }
}
async function respondToInvitation(id, accept) {
if (invitationBusy.value) return
const scope = scopeVersion
invitationBusy.value = id
try {
const result = await $api(`/api/collaboration/invitations/${encodeURIComponent(id)}/${accept ? 'accept' : 'decline'}`, { method: 'POST' })
if (!currentScope(scope)) return
await loadCollaboration(scope)
if (!currentScope(scope)) return
const room = decoratedRooms.value.find(room => room.key === collaborationKey(result.roomId))
if (accept && room) await selectRoom(room)
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
finally { if (currentScope(scope)) invitationBusy.value = '' }
}
async function sendMessage() {
const text = draft.value.trim(), request = selection()
if (!text || sending.value || !isInternal.value || !activeRoom.value?.exists) return
sending.value = true; draft.value = ''
try {
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text } })
if (!currentSelection(request)) return
mergeMessages([message]); await markRead(request); await scrollToBottom(request)
} catch (error) { } catch (error) {
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) } toast.add({ title: error?.data?.error || "Chatraum konnte nicht erstellt werden", color: "error" })
} finally { if (currentSelection(request)) sending.value = false } } finally {
creating.value = false
}
} }
async function pollMessages() {
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return const sendMessage = async () => {
const request = selection() const text = draft.value.trim()
if (!text || sending.value || !activeRoom.value?.exists) return
sending.value = true
draft.value = ""
try {
const message = await $api(`${roomEndpoint.value}/messages`, { method: "POST", body: { text } })
mergeMessages([message])
await markRead()
await scrollToBottom()
} catch (error) {
draft.value = text
toast.add({ title: error?.data?.error || "Nachricht konnte nicht gesendet werden", color: "error" })
} finally {
sending.value = false
}
}
const pollMessages = async () => {
if (pollBusy || document.hidden || !activeRoom.value?.exists) return
pollBusy = true pollBusy = true
try { try {
const result = await $api(nativeEndpoint(request.key, `/sync?afterId=${syncAfterId}`)) const result = await $api(`${roomEndpoint.value}/sync?afterId=${syncAfterId}`)
if (!currentSelection(request)) return if (result.messages?.length) {
if (result.messages?.length) { mergeMessages(result.messages); await markRead(request); await scrollToBottom(request) } mergeMessages(result.messages)
await loadUnread(request.scope) await markRead()
} catch (error) { await scrollToBottom()
if (currentSelection(request) && [401, 403, 404, 410].includes(error?.statusCode || error?.status)) {
++roomVersion; clearMessages(); void loadRoomLists(request.scope)
} }
} finally { pollBusy = false } await loadUnread()
} catch {
// Beim nächsten Intervall erneut versuchen.
} finally {
pollBusy = false
}
} }
const formatMessageTime = timestamp => new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }).format(new Date(timestamp))
async function initialize() { const formatMessageTime = (timestamp) => new Intl.DateTimeFormat("de-DE", {
const scope = ++scopeVersion day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit"
++roomVersion; clearMessages() }).format(new Date(timestamp))
baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = []
unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false onMounted(async () => {
hiddenRoomKeys.value = []; showHiddenRooms.value = false; preferenceBusy.value = ''; preferenceError.value = ''; preferencesReady.value = false try {
showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' }) await Promise.all([loadRoomLists(), loadUnread()])
activeRoomKey.value = requestedRoom(); loading.value = true if (!activeRoom.value) activeRoomKey.value = "allgemein"
await Promise.all([refreshLists(), loadRoomPreferences(scope)]) await loadActiveRoom()
if (!currentScope(scope)) return pollTimer = window.setInterval(pollMessages, 2500)
loading.value = false } catch (error) {
const room = activeRoom.value || (activeRoomKey.value === 'allgemein' ? visibleRooms.value.find(room => room.exists) : null) toast.add({ title: error?.data?.error || "Chat konnte nicht geladen werden", color: "error" })
if (room) await selectRoom(room) } finally {
} loading.value = false
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 onBeforeUnmount(() => {
const room = visibleRooms.value.find(room => room.key === requestedRoom()) if (pollTimer) window.clearInterval(pollTimer)
if (room) void selectRoom(room)
else { ++roomVersion; activeRoomKey.value = requestedRoom(); clearMessages() }
}) })
onMounted(() => {
mounted = true; void initialize()
pollTimer = window.setInterval(pollMessages, 2500)
listTimer = window.setInterval(() => { if (!document.hidden && !loading.value) void refreshLists() }, 15000)
})
onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInterval(pollTimer); clearInterval(listTimer) })
</script> </script>
<template> <template>
<div class="flex h-full min-h-0 min-w-0 w-full flex-1 overflow-hidden bg-white"> <div class="flex min-h-0 flex-1 bg-gray-50 p-4 sm:p-6">
<div class="grid h-full min-h-0 w-full flex-1 grid-cols-1 grid-rows-[minmax(8rem,30%)_minmax(0,1fr)] overflow-hidden lg:grid-rows-1" <div class="mx-auto grid h-[calc(100vh-8rem)] w-full max-w-7xl grid-cols-1 overflow-hidden rounded-xl border border-gray-200 bg-white shadow-sm lg:grid-cols-[19rem_minmax(0,1fr)_15rem]">
:class="isCollaboration ? 'lg:grid-cols-[19rem_minmax(0,1fr)]' : 'lg:grid-cols-[19rem_minmax(0,1fr)_15rem]'">
<aside class="min-h-0 overflow-y-auto border-r border-gray-200 p-4"> <aside class="min-h-0 overflow-y-auto border-r border-gray-200 p-4">
<div class="mb-4 flex items-center justify-between"> <div class="mb-4 flex items-center justify-between">
<div> <div>
@@ -288,67 +233,30 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt
</div> </div>
</form> </form>
<UButton to="/communication/collaboration" icon="i-heroicons-user-group" color="neutral" variant="outline" size="xs" class="mb-4 w-full justify-center">Unterräume und Freigaben</UButton>
<p v-if="listError" role="alert" class="mb-3 text-xs text-red-700">{{ listError }} <button class="underline" @click="loadRoomLists()">Erneut laden</button></p>
<p v-if="collaborationError" role="alert" class="mb-3 text-xs text-red-700">Zusammenarbeit: {{ collaborationError }} <button class="underline" @click="loadCollaboration()">Erneut laden</button></p>
<p v-if="preferenceError" role="alert" class="mb-3 text-xs text-red-700">Raumeinstellungen: {{ preferenceError }} <button class="underline" @click="loadRoomPreferences()">Erneut laden</button></p>
<section v-if="invitations.length" class="mb-5 space-y-2" aria-label="Einladungen">
<h2 class="px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">Einladungen</h2>
<div v-for="invitation in invitations" :key="invitation.id" class="rounded-lg border border-primary-200 bg-primary-50 p-3">
<p class="text-sm font-medium">{{ invitation.name }}</p>
<p class="text-xs text-gray-500">{{ invitation.ownerName }} · {{ invitation.role === 'reader' ? 'Nur lesen' : 'Lesen und schreiben' }}</p>
<p v-if="invitation.overviewAllowed" class="mt-1 text-xs text-gray-500">Projektübersicht freigegeben</p>
<div class="mt-2 flex gap-2">
<UButton size="xs" :disabled="!!invitationBusy" @click="respondToInvitation(invitation.id, true)">Annehmen</UButton>
<UButton size="xs" variant="ghost" color="neutral" :disabled="!!invitationBusy" @click="respondToInvitation(invitation.id, false)">Ablehnen</UButton>
</div>
</div>
</section>
<div v-if="loading" class="py-8 text-center text-sm text-gray-500">Chat wird geladen …</div> <div v-if="loading" class="py-8 text-center text-sm text-gray-500">Chat wird geladen …</div>
<div v-else class="space-y-5"> <div v-else class="space-y-5">
<section v-for="group in groupedRooms" :key="group.label"> <section v-for="group in groupedRooms" :key="group.label">
<h2 class="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">{{ group.label }}</h2> <h2 class="mb-1 px-2 text-xs font-semibold uppercase tracking-wide text-gray-400">{{ group.label }}</h2>
<div <button
v-for="room in group.rooms" v-for="room in group.rooms"
:key="room.key" :key="room.key"
class="mb-1 flex w-full items-center rounded-lg transition hover:bg-gray-100" type="button"
class="mb-1 flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition hover:bg-gray-100"
:class="room.key === activeRoomKey ? 'bg-primary-50 text-primary-700' : 'text-gray-700'" :class="room.key === activeRoomKey ? 'bg-primary-50 text-primary-700' : 'text-gray-700'"
@click="selectRoom(room)"
> >
<button <UIcon :name="room.icon" class="size-5 shrink-0" />
type="button" <span class="min-w-0 flex-1">
class="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left" <span class="block truncate text-sm font-medium">{{ room.name }}</span>
@click="selectRoom(room)" <span class="block truncate text-xs text-gray-400">{{ room.topic }}</span>
> </span>
<UIcon :name="room.icon" class="size-5 shrink-0" /> <UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
<span class="min-w-0 flex-1">
<span class="block truncate text-sm font-medium">{{ room.name }}</span>
<span class="block truncate text-xs text-gray-400">{{ room.source === 'collaboration' ? room.ownerName || 'Projekt-Unterraum' : room.topic }}{{ room.remote ? ' · Extern' : '' }}{{ room.status === 'archived' ? ' · Archiviert' : '' }}</span>
</span>
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
</button>
<UButton icon="i-heroicons-eye-slash" color="neutral" variant="ghost" size="xs" class="mr-1 shrink-0" :aria-label="`${room.name} ausblenden`" title="Für mich ausblenden" :disabled="!preferencesReady || !!preferenceBusy" @click="setRoomHidden(room, true)" />
</div>
</section>
<p v-if="!visibleRooms.length" class="px-2 text-sm text-gray-500">Keine sichtbaren Räume. Du kannst ausgeblendete Räume unten wieder einblenden.</p>
<section v-if="hiddenRooms.length" aria-label="Ausgeblendete Räume">
<button type="button" class="flex w-full items-center justify-between px-2 py-2 text-left text-xs font-semibold text-gray-500" :aria-expanded="showHiddenRooms" @click="showHiddenRooms = !showHiddenRooms">
<span>Ausgeblendete Räume ({{ hiddenRooms.length }})</span><UIcon :name="showHiddenRooms ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
</button> </button>
<div v-if="showHiddenRooms" class="space-y-1">
<div v-for="room in hiddenRooms" :key="room.key" class="flex items-center gap-2 px-2 py-1">
<span class="min-w-0 flex-1 truncate text-sm text-gray-500">{{ room.name }}</span>
<UButton icon="i-heroicons-eye" variant="ghost" color="neutral" size="xs" :aria-label="`${room.name} einblenden`" :disabled="!!preferenceBusy" @click="setRoomHidden(room, false)">Einblenden</UButton>
</div>
</div>
</section> </section>
</div> </div>
</aside> </aside>
<CollaborationRoom v-if="!loading && isCollaboration" :key="`${session}:${activeRoom.id}`" :room-id="activeRoom.id" :api="$api" embedded @changed="loadCollaboration()" @unauthorized="loadCollaboration()"> <main class="flex min-h-0 flex-col">
<template #actions><UButton :to="{ path: '/communication/collaboration', query: { room: activeRoom.id } }" color="neutral" variant="outline" size="xs">Details und Freigaben</UButton></template>
</CollaborationRoom>
<main v-else class="flex min-h-0 flex-col">
<header class="border-b border-gray-200 px-5 py-4"> <header class="border-b border-gray-200 px-5 py-4">
<h2 class="font-semibold text-gray-900">{{ activeRoom?.name || "Chat" }}</h2> <h2 class="font-semibold text-gray-900">{{ activeRoom?.name || "Chat" }}</h2>
<p class="truncate text-sm text-gray-500">{{ activeRoom?.topic || "Wähle links einen Chatraum aus." }}</p> <p class="truncate text-sm text-gray-500">{{ activeRoom?.topic || "Wähle links einen Chatraum aus." }}</p>
@@ -356,7 +264,6 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt
<div ref="messagesViewport" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6"> <div ref="messagesViewport" class="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
<div v-if="roomLoading" class="py-10 text-center text-sm text-gray-500">Nachrichten werden geladen …</div> <div v-if="roomLoading" class="py-10 text-center text-sm text-gray-500">Nachrichten werden geladen …</div>
<div v-else-if="!activeRoom" class="py-10 text-center text-sm text-gray-500">Dieser Chatraum ist nicht verfügbar. Wähle links einen anderen Raum.</div>
<div v-else-if="!messages.length" class="flex h-full flex-col items-center justify-center text-center text-gray-400"> <div v-else-if="!messages.length" class="flex h-full flex-col items-center justify-center text-center text-gray-400">
<UIcon name="i-heroicons-chat-bubble-oval-left-ellipsis" class="mb-3 size-10" /> <UIcon name="i-heroicons-chat-bubble-oval-left-ellipsis" class="mb-3 size-10" />
<p class="font-medium">Noch keine Nachrichten</p> <p class="font-medium">Noch keine Nachrichten</p>
@@ -379,7 +286,7 @@ onBeforeUnmount(() => { disposed = true; ++scopeVersion; ++roomVersion; clearInt
</form> </form>
</main> </main>
<aside v-if="!isCollaboration" class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block"> <aside class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
<h2 class="mb-3 text-sm font-semibold text-gray-900">Teilnehmer</h2> <h2 class="mb-3 text-sm font-semibold text-gray-900">Teilnehmer</h2>
<div class="space-y-3"> <div class="space-y-3">
<div v-for="member in members" :key="member.userId" class="flex items-center gap-3"> <div v-for="member in members" :key="member.userId" class="flex items-center gap-3">

View File

@@ -2,7 +2,7 @@
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const route = useRoute() const route = useRoute()
const state = ref({ rooms: [], invitations: [], managedRooms: [], projects: [], canManage: false, contact: '' }) const state = ref({ rooms: [], invitations: [], managedRooms: [], projects: [], canManage: false, contact: '' })
const contacts = ref([]), selected = ref(typeof route.query.room === 'string' ? route.query.room : ''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false) const contacts = ref([]), selected = ref(''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false)
const management = ref({ members: [], invitations: [] }), inviteResult = ref(null) const management = ref({ members: [], invitations: [] }), inviteResult = ref(null)
const create = reactive({ name: '', topic: '', projectId: String(route.query.project || '') }) const create = reactive({ name: '', topic: '', projectId: String(route.query.project || '') })
const invite = reactive({ kind: 'user', userId: '', name: '', email: '', contact: '', role: 'participant', overviewAllowed: false, expiresAt: '', sendEmail: true }) const invite = reactive({ kind: 'user', userId: '', name: '', email: '', contact: '', role: 'participant', overviewAllowed: false, expiresAt: '', sendEmail: true })
@@ -73,7 +73,6 @@ async function savePartner(origin, active = true) {
}) })
} }
watch(selected, () => { showManagement.value = false; inviteResult.value = null; management.value = { members: [], invitations: [] } }) watch(selected, () => { showManagement.value = false; inviteResult.value = null; management.value = { members: [], invitations: [] } })
watch(() => route.query.room, id => { if (typeof id === 'string') selected.value = id })
let timer let timer
onMounted(async () => { await refresh(); timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000) }) onMounted(async () => { await refresh(); timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000) })
onBeforeUnmount(() => clearInterval(timer)) onBeforeUnmount(() => clearInterval(timer))
@@ -121,10 +120,7 @@ onBeforeUnmount(() => clearInterval(timer))
</button> </button>
</aside> </aside>
<div class="min-w-0 space-y-4"> <div class="min-w-0 space-y-4">
<div class="flex flex-wrap gap-2"> <UButton v-if="selectedManaged" variant="outline" :disabled="busy" @click="run(async () => { await loadManagement(); showManagement = !showManagement })">Teilnehmer und Freigaben verwalten</UButton>
<UButton v-if="readable" :to="{ path: '/communication/chat', query: { room: `collaboration:${selected}` } }" variant="outline">Im Chat öffnen</UButton>
<UButton v-if="selectedManaged" variant="outline" :disabled="busy" @click="run(async () => { await loadManagement(); showManagement = !showManagement })">Teilnehmer und Freigaben verwalten</UButton>
</div>
<section v-if="showManagement && selectedManaged" class="space-y-5 rounded-xl border border-gray-200 p-4 dark:border-gray-700"> <section v-if="showManagement && selectedManaged" class="space-y-5 rounded-xl border border-gray-200 p-4 dark:border-gray-700">
<form class="space-y-3" @submit.prevent="invitePerson"> <form class="space-y-3" @submit.prevent="invitePerson">
<h2 class="font-semibold">Person in diesen Unterraum einladen</h2> <h2 class="font-semibold">Person in diesen Unterraum einladen</h2>

View File

@@ -1717,16 +1717,6 @@ export const useDataStore = defineStore('data', () => {
key: "customerRef", key: "customerRef",
label: "Referenz des Kunden", label: "Referenz des Kunden",
inputType: "text" inputType: "text"
},{
key: "chatEnabled",
label: "Projektchat aktiviert",
inputType: "bool",
component: active,
defaultValue: true,
disabledFunction: () => {
const auth = useAuthStore()
return !auth.user?.is_admin && !auth.hasPermission('organisation.projects.write')
}
},{ },{
key: "notes", key: "notes",
label: "Notizen", label: "Notizen",

View File

@@ -1,329 +0,0 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const vue = require('vue')
const settle = () => new Promise(setImmediate)
const deferred = () => {
let resolve
const promise = new Promise(done => { resolve = done })
return { promise, resolve }
}
const source = fs.readFileSync(`${__dirname}/../pages/communication/chat.vue`, 'utf8').match(/<script setup>([\s\S]*?)<\/script>/)[1]
const nativeRoom = { key: 'allgemein', name: 'Allgemein', exists: true, type: 'general' }
const sharedRoom = { id: 'shared-room', name: 'Partnerchat', remote: true, unread: 3 }
function fixture(override = () => undefined) {
return async (url, options) => {
const result = override(url, options)
if (result !== undefined) return result
if (url === '/api/collaboration') return { rooms: [sharedRoom], invitations: [], managedRooms: [{ id: 'admin-only' }] }
if (url === '/api/communication/chat/room-preferences') return { hiddenRoomKeys: [] }
if (url === '/api/communication/chat/rooms') return { rooms: [nativeRoom] }
if (url.endsWith('/project-rooms') || url.endsWith('/direct-rooms')) return { rooms: [] }
if (url.endsWith('/unread')) return { rooms: { allgemein: { count: 2 } } }
if (url.endsWith('/members')) return { members: [{ userId: 'member', displayName: 'Kollege' }] }
if (url.includes('/sync?')) return { messages: [] }
if (url.endsWith('/messages')) return { messages: [{ id: 1, body: 'Interne Nachricht' }] }
if (url.endsWith('/read')) return {}
throw new Error(`Unexpected request: ${url}`)
}
}
function setup(api = fixture(), query = {}) {
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1' })
const route = vue.reactive({ query })
const calls = [], toasts = [], timers = new Map()
let mount, unmount, timerId = 0
const scope = vue.effectScope()
const context = vm.createContext({
ref: vue.ref, reactive: vue.reactive, computed: vue.computed, watch: vue.watch, nextTick: vue.nextTick,
useToast: () => ({ add: item => toasts.push(item) }),
useAuthStore: () => auth, useRoute: () => route,
useRouter: () => ({ replace: async location => { route.query = location.query } }),
useNuxtApp: () => ({ $api: (url, options) => { calls.push({ url, options }); return api(url, options) } }),
onMounted: handler => { mount = handler }, onBeforeUnmount: handler => { unmount = handler },
window: { setInterval: handler => { timers.set(++timerId, handler); return timerId } },
clearInterval: id => timers.delete(id), document: { hidden: false },
})
scope.run(() => vm.runInContext(source, context))
return { auth, route, calls, toasts, timers, mount, close: () => { unmount(); scope.stop() }, run: code => vm.runInContext(code, context) }
}
test('only member rooms enter normal chat, with separate keys, groups and unread counts', async t => {
const ui = setup()
t.after(ui.close)
await ui.run('refreshLists()')
assert.equal(ui.run('decoratedRooms.value.length'), 2)
assert.equal(ui.run('decoratedRooms.value.find(r => r.source === "collaboration").key'), 'collaboration:shared-room')
assert.equal(ui.run('groupedRooms.value.at(-1).label'), 'Zusammenarbeit')
assert.equal(ui.run('decoratedRooms.value.at(-1).unread'), 3)
assert.equal(ui.run('decoratedRooms.value[0].unread'), 2)
assert.equal(ui.run('decoratedRooms.value.some(r => r.id === "admin-only")'), false)
})
test('opening a collaboration deep link never provisions, reads or sends through native chat APIs', async t => {
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('isCollaboration.value'), true)
assert.equal(ui.route.query.room, 'collaboration:shared-room')
ui.run('draft.value = "Partnernachricht"')
await ui.run('pollMessages()')
await ui.run('sendMessage()')
assert.equal(ui.calls.some(({ url }) => url.startsWith('/api/communication/chat/rooms/')), false)
ui.route.query = { room: 'allgemein' }
await settle()
assert.equal(ui.run('isInternal.value'), true)
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
ui.route.query = { room: 'collaboration:shared-room' }
await settle()
assert.equal(ui.run('isCollaboration.value'), true)
assert.equal(ui.run('messages.value.length'), 0)
})
test('accepting an invitation opens the returned local binding and clears the invitation', async t => {
let accepted = false
const ui = setup(fixture(url => {
if (url === '/api/collaboration/invitations/invite/accept') { accepted = true; return { roomId: sharedRoom.id } }
if (url === '/api/collaboration') return { rooms: accepted ? [sharedRoom] : [], invitations: accepted ? [] : [{ id: 'invite' }] }
}))
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('invitations.value.length'), 1)
await ui.run('respondToInvitation("invite", true)')
assert.equal(ui.run('invitations.value.length'), 0)
assert.equal(ui.run('isCollaboration.value'), true)
assert.equal(ui.route.query.room, 'collaboration:shared-room')
assert.equal(ui.calls.find(({ url }) => url.endsWith('/accept')).options.method, 'POST')
})
test('declining an invitation keeps the current chat open', async t => {
let declined = false
const ui = setup(fixture(url => {
if (url.endsWith('/invite/decline')) { declined = true; return { ok: true } }
if (url === '/api/collaboration') return { rooms: [], invitations: declined ? [] : [{ id: 'invite' }] }
}))
t.after(ui.close)
ui.mount()
await settle()
await ui.run('respondToInvitation("invite", false)')
assert.equal(ui.run('invitations.value.length'), 0)
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
})
test('a late native room load cannot populate a collaboration room or mark it read', async t => {
const pending = deferred()
const ui = setup(fixture(url => url.endsWith('/allgemein/messages') ? pending.promise : undefined))
t.after(ui.close)
ui.mount()
await settle()
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
pending.resolve({ messages: [{ id: 99, body: 'Late private content' }] })
await settle()
assert.equal(ui.run('messages.value.length'), 0)
assert.equal(ui.run('members.value.length'), 0)
assert.equal(ui.run('roomLoading.value'), false)
assert.equal(ui.calls.some(({ url }) => url.endsWith('/read')), false)
})
test('late sync and send results cannot overwrite the selected collaboration room', async t => {
const sync = deferred(), send = deferred()
const ui = setup(fixture((url, options) => {
if (url.includes('/sync?')) return sync.promise
if (url.endsWith('/messages') && options?.method === 'POST') return send.promise
}))
t.after(ui.close)
ui.mount()
await settle()
const polling = ui.run('pollMessages()')
ui.run('draft.value = "Internal draft"')
const sending = ui.run('sendMessage()')
await ui.run('selectRoom(decoratedRooms.value.find(r => r.source === "collaboration"))')
const reads = ui.calls.filter(({ url }) => url.endsWith('/read')).length
sync.resolve({ messages: [{ id: 2, body: 'Late sync' }] })
send.resolve({ id: 3, body: 'Late sent message' })
await Promise.all([polling, sending])
assert.equal(ui.run('messages.value.length'), 0)
assert.equal(ui.run('draft.value'), '')
assert.equal(ui.run('sending.value'), false)
assert.equal(ui.calls.filter(({ url }) => url.endsWith('/read')).length, reads)
})
test('switching tenants clears visible content immediately and ignores older room lists', async t => {
const stale = deferred(), fresh = deferred()
let lists = 0
const ui = setup(fixture(url => {
if (url === '/api/collaboration') return ++lists === 1 ? stale.promise : fresh.promise
}))
t.after(ui.close)
ui.mount()
ui.run('messages.value = [{ id: 1, body: "Previous tenant" }]; draft.value = "Private draft"')
ui.auth.activeTenant = '2'
assert.equal(ui.run('messages.value.length'), 0)
assert.equal(ui.run('collaborationRooms.value.length'), 0)
assert.equal(ui.run('draft.value'), '')
assert.equal(ui.run('session.value'), 'user-1:2')
fresh.resolve({ rooms: [{ id: 'tenant-2' }], invitations: [] })
await settle()
stale.resolve({ rooms: [{ id: 'tenant-1-secret' }], invitations: [{ id: 'private-invite' }] })
await settle()
assert.equal(ui.run('collaborationRooms.value[0].id'), 'tenant-2')
assert.equal(ui.run('invitations.value.length'), 0)
})
test('collaboration and internal service failures do not block the other room type', async t => {
const ui = setup(fixture(url => {
if (url === '/api/collaboration') throw { statusCode: 503, data: { message: 'Unterräume nicht erreichbar' } }
}))
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('isInternal.value'), true)
assert.equal(ui.run('messages.value.length'), 1)
assert.equal(ui.run('collaborationError.value'), 'Unterräume nicht erreichbar')
const other = setup(fixture(url => {
if (url === '/api/communication/chat/rooms') throw { statusCode: 409, data: { message: 'Chat nicht verfügbar' } }
}), { room: 'collaboration:shared-room' })
t.after(other.close)
other.mount()
await settle()
assert.equal(other.run('isCollaboration.value'), true)
assert.equal(other.run('listError.value'), 'Chat nicht verfügbar')
})
test('revoked membership removes the active collaboration room instead of exposing managed rooms', async t => {
let revoked = false
const ui = setup(fixture(url => {
if (url === '/api/collaboration' && revoked) return { rooms: [], invitations: [], managedRooms: [sharedRoom] }
}), { room: 'collaboration:shared-room' })
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('isCollaboration.value'), true)
revoked = true
await ui.run('loadCollaboration()')
assert.equal(ui.run('activeRoom.value'), null)
assert.equal(ui.run('isCollaboration.value'), false)
assert.equal(ui.run('isInternal.value'), false)
})
test('logout clears the room and invitations without starting requests with the previous credentials', async t => {
const ui = setup(fixture(), { room: 'collaboration:shared-room' })
t.after(ui.close)
ui.mount()
await settle()
const calls = ui.calls.length
ui.auth.user = null
await settle()
await ui.run('refreshLists()')
assert.equal(ui.run('activeRoom.value'), null)
assert.equal(ui.run('invitations.value.length'), 0)
assert.equal(ui.calls.length, calls)
})
test('unmount cancels both polling timers and ignores pending lists', async () => {
const pending = deferred()
const ui = setup(fixture(url => url === '/api/collaboration' ? pending.promise : undefined))
ui.mount()
assert.equal(ui.timers.size, 2)
ui.close()
pending.resolve({ rooms: [sharedRoom], invitations: [] })
await settle()
assert.equal(ui.timers.size, 0)
assert.equal(ui.run('collaborationRooms.value.length'), 0)
assert.equal(ui.run('messages.value.length'), 0)
})
test('hiding the active room persists the preference, switches away and allows restoring it', async t => {
const ui = setup()
t.after(ui.close)
ui.mount()
await settle()
await ui.run('setRoomHidden(activeRoom.value, true)')
assert.equal(ui.run('hiddenRooms.value[0].key'), 'allgemein')
assert.equal(ui.run('visibleRooms.value.some(room => room.key === "allgemein")'), false)
assert.equal(ui.run('isCollaboration.value'), true)
assert.equal(ui.run('messages.value.length'), 0)
const save = ui.calls.find(call => call.url.endsWith('/room-preferences') && call.options?.method === 'PUT')
assert.equal(save.options.body.roomKey, 'allgemein')
assert.equal(save.options.body.hidden, true)
await ui.run('setRoomHidden(hiddenRooms.value[0], false)')
assert.equal(ui.run('hiddenRooms.value.length'), 0)
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
})
test('saved hidden rooms stay hidden on reload, including federated room deep links', async t => {
const ui = setup(fixture(url => url.endsWith('/room-preferences') ? { hiddenRoomKeys: ['collaboration:shared-room', 'allgemein'] } : undefined), { room: 'collaboration:shared-room' })
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('visibleRooms.value.length'), 0)
assert.equal(ui.run('activeRoom.value'), null)
assert.equal(ui.calls.some(({ url }) => /\/rooms\/.+\/(messages|read)/.test(url)), false)
await ui.run('selectRoom(decoratedRooms.value[0])')
assert.equal(ui.run('activeRoom.value'), null)
await ui.run('setRoomHidden(hiddenRooms.value.find(r => r.source === "collaboration"), false)')
assert.equal(ui.run('isCollaboration.value'), true)
})
test('preference save failure leaves the active conversation and list unchanged', async t => {
const ui = setup(fixture((url, options) => {
if (url.endsWith('/room-preferences') && options?.method === 'PUT') throw { data: { message: 'Speichern fehlgeschlagen' } }
}))
t.after(ui.close)
ui.mount()
await settle()
await ui.run('setRoomHidden(activeRoom.value, true)')
assert.equal(ui.run('hiddenRooms.value.length'), 0)
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
assert.equal(ui.run('messages.value[0].body'), 'Interne Nachricht')
assert.equal(ui.toasts.at(-1).title, 'Speichern fehlgeschlagen')
})
test('pending hide operations cannot change a new tenant, and hiding the last room clears its content', async t => {
const pending = deferred()
const ui = setup(fixture((url, options) => {
if (url.endsWith('/room-preferences') && options?.method === 'PUT') return pending.promise
}))
t.after(ui.close)
ui.mount()
await settle()
const hiding = ui.run('setRoomHidden(activeRoom.value, true)')
ui.auth.activeTenant = '2'
await settle()
pending.resolve({ hidden: true, roomKey: 'allgemein' })
await hiding
assert.equal(ui.run('hiddenRoomKeys.value.length'), 0)
assert.equal(ui.run('activeRoomKey.value'), 'allgemein')
const single = setup(fixture(url => url === '/api/collaboration' ? { rooms: [], invitations: [] } : undefined))
t.after(single.close)
single.mount()
await settle()
await single.run('setRoomHidden(activeRoom.value, true)')
await settle()
assert.equal(single.run('activeRoom.value'), null)
assert.equal(single.run('messages.value.length'), 0)
assert.equal(single.run('members.value.length'), 0)
assert.equal(single.run('visibleRooms.value.length'), 0)
})
test('removing an active project from the permitted room list clears its messages and members', async t => {
let disabled = false
const ui = setup(fixture(url => url.endsWith('/project-rooms') ? { rooms: disabled ? [] : [{ key: 'project_1', exists: true, type: 'project' }] } : undefined), { room: 'project_1' })
t.after(ui.close)
ui.mount()
await settle()
assert.equal(ui.run('messages.value.length'), 1)
disabled = true
await ui.run('loadRoomLists()')
await settle()
assert.equal(ui.run('activeRoom.value'), null)
assert.equal(ui.run('messages.value.length'), 0)
assert.equal(ui.run('members.value.length'), 0)
})

View File

@@ -82,14 +82,14 @@ async function sendTestPush() {
}); });
await Promise.all([refreshInstance(), refreshJobs(), refreshDevices()]); await Promise.all([refreshInstance(), refreshJobs(), refreshDevices()]);
toast.add({ toast.add({
title: result.failed ? (result.sent ? "Test-Push teilweise fehlgeschlagen" : "Test-Push fehlgeschlagen") : "Test-Push gesendet", title: "Test-Push gesendet",
description: [`${result.sent ?? 0} gesendet, ${result.failed ?? 0} fehlgeschlagen`, result.lastErrorCode, result.lastErrorMessage].filter(Boolean).join(" · "), description: `${result.sent ?? 0} gesendet, ${result.failed ?? 0} fehlgeschlagen`,
color: result.failed ? (result.sent ? "warning" : "error") : "success", color: result.failed ? "warning" : "success",
}); });
} catch (error: any) { } catch (error: any) {
toast.add({ toast.add({
title: "Test-Push fehlgeschlagen", title: "Test-Push fehlgeschlagen",
description: error?.data?.message || error?.data?.error || error?.message || "Die Testnachricht konnte nicht gesendet werden.", description: error?.data?.message || error?.message || "Die Testnachricht konnte nicht gesendet werden.",
color: "error", color: "error",
}); });
} finally { } finally {
@@ -246,7 +246,6 @@ async function sendTestPush() {
<th>Angenommen</th> <th>Angenommen</th>
<th>Gesendet</th> <th>Gesendet</th>
<th>Fehler</th> <th>Fehler</th>
<th>Letzter Fehler</th>
<th>Erstellt</th> <th>Erstellt</th>
</tr> </tr>
</thead> </thead>
@@ -257,10 +256,6 @@ async function sendTestPush() {
<td>{{ job.acceptedCount }}</td> <td>{{ job.acceptedCount }}</td>
<td>{{ job.sentCount }}</td> <td>{{ job.sentCount }}</td>
<td>{{ job.failedCount }}</td> <td>{{ job.failedCount }}</td>
<td>
<div>{{ job.lastErrorCode || "-" }}</div>
<div v-if="job.lastErrorMessage" class="text-sm text-gray-500">{{ job.lastErrorMessage }}</div>
</td>
<td>{{ new Date(job.createdAt).toLocaleString() }}</td> <td>{{ new Date(job.createdAt).toLocaleString() }}</td>
</tr> </tr>
</tbody> </tbody>