Compare commits
11 Commits
30e932a874
...
v3.11.0
| Author | SHA1 | Date | |
|---|---|---|---|
| daaed01d02 | |||
| 7e6c2fbac5 | |||
| 0887863981 | |||
| 6ff9971ff9 | |||
| e7d6c482ad | |||
| 1885bfc521 | |||
| 09a002d600 | |||
| ed305f656c | |||
| d7382e7319 | |||
| 64299843e2 | |||
| b01cf2c207 |
12
.env.example
12
.env.example
@@ -51,11 +51,13 @@ SEAFILE_ADMIN_PASSWORD=change-this-seafile-admin-password
|
||||
|
||||
M2M_API_KEY=change-this-m2m-key
|
||||
API_BASE_URL=https://app.example.com/backend
|
||||
# Collaboration: public browser URL and canonical HTTPS API address (including reverse-proxy prefix).
|
||||
FEDEO_PUBLIC_URL=https://app.example.com
|
||||
FEDEO_FEDERATION_URL=https://app.example.com/backend
|
||||
# Requires mutual tenant partner approval under Zusammenarbeit.
|
||||
FEDEO_FEDERATION_ENABLED=false
|
||||
# Collaboration uses API_BASE_URL for federation and its origin for guest links.
|
||||
# Optional overrides only, e.g. when using a separate federation gateway.
|
||||
FEDEO_PUBLIC_URL=
|
||||
FEDEO_FEDERATION_URL=
|
||||
# auto: enabled with a valid HTTPS API address and ENCRYPTION_KEY; false: opt out.
|
||||
# Partners still require mutual tenant approval under Zusammenarbeit.
|
||||
FEDEO_FEDERATION_ENABLED=auto
|
||||
GOCARDLESS_BASE_URL=https://api.gocardless.com
|
||||
GOCARDLESS_SECRET_ID=replace-this
|
||||
GOCARDLESS_SECRET_KEY=replace-this
|
||||
|
||||
@@ -87,6 +87,8 @@ Minimal wichtige Werte fur den ersten Start:
|
||||
- `DATABASE_URL=postgres://fedeo:<starkes-passwort>@db:5432/fedeo`
|
||||
- `API_BASE_URL=https://app.example.com/backend`
|
||||
|
||||
Die Chat-Föderation verwendet ebenfalls `API_BASE_URL`; Gastlinks übernehmen deren Ursprung. Mit gültiger öffentlicher HTTPS-Adresse auf Port 443 und vorhandenem gültigem `ENCRYPTION_KEY` ist sie automatisch verfügbar. Zusätzliche URLs sind nur bei abweichender Infrastruktur nötig (`FEDEO_FEDERATION_URL`, `FEDEO_PUBLIC_URL`). `FEDEO_FEDERATION_ENABLED=false` schaltet sie ausdrücklich ab; der Standard ist `auto`. Der Updater übernimmt die passenden Compose-Einträge aus dem Release. Partner müssen anschließend weiterhin je Mandant freigegeben werden; siehe [Föderation einrichten](docs/chat-foederation-konzept.md#15-implementierter-stand-und-pilotbetrieb).
|
||||
|
||||
Wenn du MinIO verwendest, setze zusatzlich:
|
||||
|
||||
- `S3_ENDPOINT=http://minio:9000`
|
||||
|
||||
13
backend/db/migrations/0077_scanner_inbox_preparation.sql
Normal file
13
backend/db/migrations/0077_scanner_inbox_preparation.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
ALTER TABLE "scanner_inbox_documents"
|
||||
ADD COLUMN "suggestion_status" text NOT NULL DEFAULT 'pending',
|
||||
ADD COLUMN "suggestion" jsonb,
|
||||
ADD COLUMN "suggestion_message" text,
|
||||
ADD COLUMN "suggestion_claim" uuid,
|
||||
ADD COLUMN "suggestion_updated_at" timestamptz NOT NULL DEFAULT now();
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "scanner_inbox_documents" ADD CONSTRAINT "scanner_inbox_suggestion_status_check"
|
||||
CHECK ("suggestion_status" IN ('pending', 'processing', 'ready', 'failed'));
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX "scanner_inbox_suggestion_queue_idx"
|
||||
ON "scanner_inbox_documents" ("suggestion_status", "suggestion_updated_at", "received_at")
|
||||
WHERE "filed_at" IS NULL;
|
||||
8
backend/db/migrations/0078_chat_visibility.sql
Normal file
8
backend/db/migrations/0078_chat_visibility.sql
Normal file
@@ -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")
|
||||
);
|
||||
@@ -519,6 +519,20 @@
|
||||
"when": 1790726400001,
|
||||
"tag": "0076_collaboration_federation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 74,
|
||||
"version": "7",
|
||||
"when": 1790899200000,
|
||||
"tag": "0077_scanner_inbox_preparation",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 75,
|
||||
"version": "7",
|
||||
"when": 1790985600000,
|
||||
"tag": "0078_chat_visibility",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
10
backend/db/schema/communication_hidden_rooms.ts
Normal file
10
backend/db/schema/communication_hidden_rooms.ts
Normal file
@@ -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] }) }))
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { bigint, boolean, index, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"
|
||||
import { bigint, boolean, index, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"
|
||||
import { tenants } from "./tenants"
|
||||
import { files } from "./files"
|
||||
import { sql } from "drizzle-orm"
|
||||
|
||||
export const scannerInboxConnections = pgTable("scanner_inbox_connections", {
|
||||
tenantId: bigint("tenant_id", { mode: "number" }).primaryKey().references(() => tenants.id, { onDelete: "cascade" }),
|
||||
@@ -18,7 +19,13 @@ export const scannerInboxDocuments = pgTable("scanner_inbox_documents", {
|
||||
checksum: text("checksum").notNull(),
|
||||
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
filedAt: timestamp("filed_at", { withTimezone: true }),
|
||||
suggestionStatus: text("suggestion_status").notNull().default("pending"),
|
||||
suggestion: jsonb("suggestion"),
|
||||
suggestionMessage: text("suggestion_message"),
|
||||
suggestionClaim: uuid("suggestion_claim"),
|
||||
suggestionUpdatedAt: timestamp("suggestion_updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
}, table => ({
|
||||
filenameKey: uniqueIndex("scanner_inbox_documents_filename_key").on(table.tenantId, table.remoteFilename),
|
||||
receivedIdx: index("scanner_inbox_documents_received_idx").on(table.tenantId, table.receivedAt),
|
||||
suggestionQueueIdx: index("scanner_inbox_suggestion_queue_idx").on(table.suggestionStatus, table.suggestionUpdatedAt, table.receivedAt).where(sql`${table.filedAt} IS NULL`),
|
||||
}))
|
||||
|
||||
@@ -69,6 +69,7 @@ import {initS3} from "./utils/s3";
|
||||
import { runBootstrap } from "./modules/bootstrap.service";
|
||||
import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service";
|
||||
import { startDocumentImportWorker } from "./modules/document-import/document-import.worker";
|
||||
import { startScannerInboxWorker } from "./modules/scanner-inbox.worker";
|
||||
|
||||
|
||||
//Services
|
||||
@@ -97,6 +98,7 @@ async function main() {
|
||||
await runBootstrap(app);
|
||||
startCentralServicesHeartbeat(app);
|
||||
startDocumentImportWorker(app);
|
||||
startScannerInboxWorker(app);
|
||||
|
||||
app.addHook('preHandler', (req, reply, done) => {
|
||||
console.log(req.method)
|
||||
|
||||
30
backend/src/modules/collaboration/config.ts
Normal file
30
backend/src/modules/collaboration/config.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { instanceUrl } from "./transport"
|
||||
|
||||
type Settings = {
|
||||
API_BASE_URL?: string;
|
||||
ENCRYPTION_KEY?: string;
|
||||
FEDEO_PUBLIC_URL?: string;
|
||||
FEDEO_FEDERATION_URL?: string;
|
||||
FEDEO_FEDERATION_ENABLED?: string;
|
||||
}
|
||||
|
||||
/** Existing deployment settings are sufficient; extra URLs are optional overrides. */
|
||||
export function collaborationConfig(settings: Settings) {
|
||||
const configuredApi = settings.API_BASE_URL?.trim() || ""
|
||||
let apiUrl = (settings.FEDEO_FEDERATION_URL?.trim() || configuredApi).replace(/\/+$/, "")
|
||||
let publicUrl = settings.FEDEO_PUBLIC_URL?.trim().replace(/\/+$/, "") || ""
|
||||
if (!publicUrl) {
|
||||
try { publicUrl = new URL(configuredApi || apiUrl).origin } catch { /* Local development may have no public URL. */ }
|
||||
}
|
||||
const mode = settings.FEDEO_FEDERATION_ENABLED?.trim().toLowerCase() || "auto"
|
||||
if (!["auto", "true", "false"].includes(mode)) throw new Error("FEDEO_FEDERATION_ENABLED muss auto, true oder false sein")
|
||||
if (mode === "false") return { apiUrl, publicUrl, enabled: false, disabledReason: "Die Föderation wurde vom Betreiber deaktiviert." }
|
||||
|
||||
let disabledReason: string | undefined
|
||||
try { apiUrl = instanceUrl(apiUrl) }
|
||||
catch { disabledReason = "Föderation benötigt eine gültige öffentliche HTTPS-API_BASE_URL auf Port 443." }
|
||||
if (!disabledReason && !/^[a-f0-9]{64}$/i.test(settings.ENCRYPTION_KEY || "")) {
|
||||
disabledReason = "Föderation benötigt einen gültigen ENCRYPTION_KEY mit 64 Hex-Zeichen." }
|
||||
if (mode === "true" && disabledReason) throw new Error(disabledReason)
|
||||
return { apiUrl, publicUrl, enabled: !disabledReason, disabledReason }
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { FederationKey, Headers, instanceUrl, safeTransport, signedHeaders, Tran
|
||||
|
||||
type Options = {
|
||||
apiUrl: string; enabled: boolean;
|
||||
disabledReason?: string;
|
||||
seal: (value: string) => any; unseal: (value: any) => string;
|
||||
transport?: Transport;
|
||||
}
|
||||
@@ -22,7 +23,7 @@ export class FederationService {
|
||||
this.transport = options.transport || safeTransport
|
||||
}
|
||||
enabled() {
|
||||
if (!this.options.enabled) fail("Der Betreiber muss die Föderation zuerst aktivieren", 503)
|
||||
if (!this.options.enabled) fail(this.options.disabledReason || "Der Betreiber muss die Föderation zuerst aktivieren", 503)
|
||||
instanceUrl(this.origin)
|
||||
}
|
||||
async keys() {
|
||||
@@ -71,7 +72,7 @@ export class FederationService {
|
||||
VALUES (${ctx.tenantId},${origin},${instanceId},${keyId},${publicKey}) ON CONFLICT(tenant_id,origin) DO UPDATE SET active=true,updated_at=now()`)
|
||||
}
|
||||
}
|
||||
return { enabled: this.options.enabled, origin: this.origin, partners: (await rows(this.service.db,
|
||||
return { enabled: this.options.enabled, disabledReason: this.options.disabledReason, origin: this.origin, partners: (await rows(this.service.db,
|
||||
sql`SELECT origin,active,public_key FROM collaboration_partners WHERE tenant_id=${ctx.tenantId} ORDER BY origin`)).map(p => ({ origin: p.origin, active: p.active, fingerprint: hash(p.public_key) })) }
|
||||
}
|
||||
async call(tenantId: number, origin: string, action: string, payload: any) {
|
||||
|
||||
@@ -4,16 +4,18 @@ import { sendMail } from "../../utils/mailer"
|
||||
import { CollaborationService } from "./service"
|
||||
import { FederationService } from "./federation"
|
||||
import { encrypt, decrypt } from "../../utils/crypt"
|
||||
import { collaborationConfig } from "./config"
|
||||
|
||||
export function collaborationRuntime(server: FastifyInstance) {
|
||||
const apiUrl = String(process.env.FEDEO_FEDERATION_URL || secrets.API_BASE_URL || "").replace(/\/$/, "")
|
||||
const publicUrl = String(process.env.FEDEO_PUBLIC_URL || (apiUrl ? new URL(apiUrl).origin : "")).replace(/\/$/, "")
|
||||
const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({
|
||||
...process.env, API_BASE_URL: secrets.API_BASE_URL, ENCRYPTION_KEY: secrets.ENCRYPTION_KEY,
|
||||
})
|
||||
const service = new CollaborationService(server.db, {
|
||||
publicUrl,
|
||||
guestPepper: () => secrets.ENCRYPTION_KEY || "",
|
||||
sendMail,
|
||||
})
|
||||
const federation = new FederationService(service, { apiUrl, enabled: process.env.FEDEO_FEDERATION_ENABLED === "true", seal: encrypt, unseal: decrypt })
|
||||
const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt })
|
||||
service.options.changed = (roomId, db) => federation.changed(roomId, db)
|
||||
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
|
||||
let timer: ReturnType<typeof setInterval>
|
||||
|
||||
@@ -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<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) {
|
||||
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
|
||||
|
||||
70
backend/src/modules/scanner-inbox-assignments.ts
Normal file
70
backend/src/modules/scanner-inbox-assignments.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { and, asc, eq, ilike, isNull, sql, type SQL } from "drizzle-orm"
|
||||
import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { authProfiles, checks, contracts, createddocuments, customers, incominginvoices, inventoryitems, plants, products, projects, vehicles, vendors } from "../../db/schema"
|
||||
|
||||
type Target = { label: string; table: AnyPgTable; id: AnyPgColumn; tenant: AnyPgColumn; name: AnyPgColumn; available: SQL; uuid?: boolean }
|
||||
export const assignmentTargets: Record<string, Target> = {
|
||||
project: { label: "Projekt", table: projects, id: projects.id, tenant: projects.tenant, name: projects.name, available: eq(projects.archived, false) },
|
||||
customer: { label: "Kunde", table: customers, id: customers.id, tenant: customers.tenant, name: customers.name, available: eq(customers.archived, false) },
|
||||
vendor: { label: "Lieferant", table: vendors, id: vendors.id, tenant: vendors.tenant, name: vendors.name, available: eq(vendors.archived, false) },
|
||||
vehicle: { label: "Fahrzeug", table: vehicles, id: vehicles.id, tenant: vehicles.tenant, name: vehicles.license_plate, available: eq(vehicles.archived, false) },
|
||||
plant: { label: "Objekt", table: plants, id: plants.id, tenant: plants.tenant, name: plants.name, available: eq(plants.archived, false) },
|
||||
contract: { label: "Vertrag", table: contracts, id: contracts.id, tenant: contracts.tenant, name: contracts.name, available: eq(contracts.archived, false) },
|
||||
product: { label: "Produkt", table: products, id: products.id, tenant: products.tenant, name: products.name, available: eq(products.archived, false) },
|
||||
createddocument: { label: "Ausgangsbeleg", table: createddocuments, id: createddocuments.id, tenant: createddocuments.tenant, name: createddocuments.documentNumber, available: eq(createddocuments.archived, false) },
|
||||
incominginvoice: { label: "Eingangsrechnung", table: incominginvoices, id: incominginvoices.id, tenant: incominginvoices.tenant, name: incominginvoices.reference, available: eq(incominginvoices.archived, false) },
|
||||
inventoryitem: { label: "Inventarartikel", table: inventoryitems, id: inventoryitems.id, tenant: inventoryitems.tenant, name: inventoryitems.name, available: eq(inventoryitems.archived, false) },
|
||||
check: { label: "Überprüfung", table: checks, id: checks.id, tenant: checks.tenant, name: checks.name, available: eq(checks.archived, false), uuid: true },
|
||||
authProfile: { label: "Mitarbeiter", table: authProfiles, id: authProfiles.id, tenant: authProfiles.tenant_id, name: authProfiles.full_name,
|
||||
available: and(eq(authProfiles.active, true), isNull(authProfiles.customer_for_portal)), uuid: true },
|
||||
}
|
||||
export const assignmentKinds = Object.entries(assignmentTargets).map(([value, target]) => ({ value, label: target.label }))
|
||||
export type FileAssignments = Record<string, string | number | null>
|
||||
const invalidAssignment = () => Object.assign(new Error("Die gewählte Zuordnung ist nicht verfügbar."), { statusCode: 400 })
|
||||
const targetFor = (kind: string) => {
|
||||
if (!Object.prototype.hasOwnProperty.call(assignmentTargets, kind)) throw invalidAssignment()
|
||||
return assignmentTargets[kind]
|
||||
}
|
||||
export function assignmentId(kind: string, id: unknown) {
|
||||
const target = targetFor(kind)
|
||||
if (target.uuid) {
|
||||
if (typeof id !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) throw invalidAssignment()
|
||||
return id
|
||||
}
|
||||
if (!/^[1-9]\d*$/.test(String(id)) || !Number.isSafeInteger(Number(id))) throw invalidAssignment()
|
||||
return Number(id)
|
||||
}
|
||||
type Reader = Pick<FastifyInstance["db"], "select">
|
||||
export async function assignmentOptions(db: Reader, tenantId: number, kind: string, search = "") {
|
||||
const target = targetFor(kind)
|
||||
const name = sql<string>`coalesce(nullif(${target.name}, ''), '#' || ${target.id}::text)`
|
||||
const rows = await db.select({ id: target.id, name }).from(target.table)
|
||||
.where(and(eq(target.tenant, tenantId), target.available,
|
||||
search ? ilike(name, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined))
|
||||
.orderBy(asc(target.name), asc(target.id)).limit(100)
|
||||
return rows.map(row => ({ id: String(row.id), name: row.name }))
|
||||
}
|
||||
export async function currentAssignments(db: Reader, tenantId: number, file: Record<string, unknown>) {
|
||||
const assignments = []
|
||||
for (const [kind, target] of Object.entries(assignmentTargets)) {
|
||||
if (file[kind] == null) continue
|
||||
const [entry] = await db.select({ name: target.name }).from(target.table)
|
||||
.where(and(eq(target.id, file[kind]), eq(target.tenant, tenantId), target.available)).limit(1)
|
||||
assignments.push({ kind, id: String(file[kind]), name: entry?.name || (entry ? `#${file[kind]}` : "Nicht mehr verfügbar"), available: Boolean(entry) })
|
||||
}
|
||||
return assignments
|
||||
}
|
||||
export async function validateAssignments(tx: Reader, tenantId: number, assignments: FileAssignments) {
|
||||
const changes: FileAssignments = {}
|
||||
for (const [kind, value] of Object.entries(assignments)) {
|
||||
const target = targetFor(kind)
|
||||
if (value === null) { changes[kind] = null; continue }
|
||||
const id = assignmentId(kind, value)
|
||||
const [row] = await tx.select({ id: target.id }).from(target.table)
|
||||
.where(and(eq(target.id, id), eq(target.tenant, tenantId), target.available)).for("share").limit(1)
|
||||
if (!row) throw invalidAssignment()
|
||||
changes[kind] = id
|
||||
}
|
||||
return changes
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
|
||||
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
|
||||
import { and, asc, count, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
|
||||
import { files, filetags, folders, scannerInboxConnections, scannerInboxDocuments, tenants } from "../../db/schema"
|
||||
import { sanitizeFilename } from "../utils/filename"
|
||||
import { s3 } from "../utils/s3"
|
||||
import { secrets } from "../utils/secrets"
|
||||
import { suggestDocumentFiling, validateFilingSuggestion, type FilingSuggestionResult } from "./scanner-inbox-suggestions"
|
||||
import { suggestDocumentFiling, validateFilingSuggestion } from "./scanner-inbox-suggestions"
|
||||
import { assignmentKinds, assignmentOptions, currentAssignments, validateAssignments, type FileAssignments } from "./scanner-inbox-assignments"
|
||||
|
||||
export const SCANNER_UPLOAD_LIMIT = 100 * 1024 * 1024
|
||||
export const SCANNER_DAV_PATH = "/scanner-inbox/"
|
||||
@@ -47,7 +48,6 @@ export function validateScannerFile(filename: string, body: Buffer) {
|
||||
}
|
||||
|
||||
export function createScannerInboxService(db: FastifyInstance["db"], storage: ScannerStorage = objectStorage, analyze = suggestDocumentFiling) {
|
||||
const pendingSuggestions = new Map<string, Promise<FilingSuggestionResult>>()
|
||||
const publicConnection = {
|
||||
username: scannerInboxConnections.username,
|
||||
active: scannerInboxConnections.active,
|
||||
@@ -93,13 +93,19 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
||||
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false),
|
||||
search ? ilike(scannerInboxDocuments.remoteFilename, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined)
|
||||
const documents = await db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
|
||||
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt })
|
||||
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt, suggestionStatus: scannerInboxDocuments.suggestionStatus })
|
||||
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
||||
.where(condition).orderBy(desc(scannerInboxDocuments.receivedAt), desc(files.id)).limit(limit).offset(offset)
|
||||
const [total] = await db.select({ value: count() }).from(scannerInboxDocuments)
|
||||
.innerJoin(files, eq(files.id, scannerInboxDocuments.fileId)).where(condition)
|
||||
return { documents, total: total.value }
|
||||
}
|
||||
const openCount = async (tenantId: number) => {
|
||||
const [row] = await db.select({ total: count() }).from(scannerInboxDocuments)
|
||||
.innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
||||
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId), isNull(scannerInboxDocuments.filedAt), eq(files.archived, false)))
|
||||
return row
|
||||
}
|
||||
const filingOptions = async (tenantId: number) => {
|
||||
const [availableFolders, types] = await Promise.all([
|
||||
db.select({ id: folders.id, name: folders.name, parent: folders.parent,
|
||||
@@ -108,33 +114,100 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
||||
db.select({ id: filetags.id, name: filetags.name }).from(filetags)
|
||||
.where(and(eq(filetags.tenant, tenantId), eq(filetags.archived, false))).orderBy(asc(filetags.name)),
|
||||
])
|
||||
return { folders: availableFolders, types }
|
||||
return { folders: availableFolders, types, assignmentKinds }
|
||||
}
|
||||
const suggestionDocument = async (tenantId: number, fileId: string) => {
|
||||
const [document] = await db.select({ path: files.path, name: scannerInboxDocuments.remoteFilename, mimeType: files.mimeType, size: files.size })
|
||||
const [document] = await db.select({ path: files.path, name: scannerInboxDocuments.remoteFilename, mimeType: files.mimeType, size: files.size,
|
||||
status: scannerInboxDocuments.suggestionStatus, suggestion: scannerInboxDocuments.suggestion, message: scannerInboxDocuments.suggestionMessage })
|
||||
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
|
||||
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId), eq(files.id, fileId),
|
||||
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false))).limit(1)
|
||||
if (!document?.path) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
|
||||
return document
|
||||
}
|
||||
const suggestFiling = async (tenantId: number, fileId: string): Promise<FilingSuggestionResult> => {
|
||||
// Check tenant ownership before reading storage or submitting anything to the AI provider.
|
||||
const getSuggestion = async (tenantId: number, fileId: string) => {
|
||||
const document = await suggestionDocument(tenantId, fileId)
|
||||
const key = `${tenantId}:${fileId}`
|
||||
if (pendingSuggestions.has(key)) return pendingSuggestions.get(key)!
|
||||
if (pendingSuggestions.size >= 4) throw scannerError(429, "Die KI verarbeitet gerade weitere Dokumente. Bitte gleich erneut versuchen.")
|
||||
const operation = (async () => {
|
||||
const result = await analyze(document, await filingOptions(tenantId))
|
||||
await suggestionDocument(tenantId, fileId)
|
||||
const suggestion = validateFilingSuggestion(result, await filingOptions(tenantId))
|
||||
return { suggestion, message: suggestion ? undefined : result.message || "Keine passende Zuordnung verfügbar. Bitte manuell auswählen." }
|
||||
})()
|
||||
pendingSuggestions.set(key, operation)
|
||||
try { return await operation }
|
||||
finally { pendingSuggestions.delete(key) }
|
||||
const suggestion = document.status === "ready"
|
||||
? validateFilingSuggestion({ suggestion: document.suggestion }, await filingOptions(tenantId)) : null
|
||||
return { status: document.status, suggestion, message: document.status === "ready" && !suggestion
|
||||
? document.message || "Keine passende Zuordnung verfügbar. Bitte manuell auswählen." : document.message }
|
||||
}
|
||||
const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string) => db.transaction(async tx => {
|
||||
const queueSuggestion = async (tenantId: number, fileId: string) => {
|
||||
await suggestionDocument(tenantId, fileId)
|
||||
await db.update(scannerInboxDocuments).set({ suggestionStatus: "pending", suggestion: null, suggestionMessage: null,
|
||||
suggestionClaim: null, suggestionUpdatedAt: new Date() })
|
||||
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.fileId, fileId),
|
||||
isNull(scannerInboxDocuments.filedAt), inArray(scannerInboxDocuments.suggestionStatus, ["ready", "failed"])))
|
||||
return getSuggestion(tenantId, fileId)
|
||||
}
|
||||
const processNextSuggestion = async () => {
|
||||
const claim = randomUUID()
|
||||
// Claim without holding a transaction open while OCR/the provider runs. Expired claims survive restarts.
|
||||
const claimed = await db.execute<{ fileId: string; tenantId: string }>(sql`
|
||||
UPDATE scanner_inbox_documents AS job
|
||||
SET suggestion_status = 'processing', suggestion_claim = ${claim}, suggestion_updated_at = now()
|
||||
WHERE job.file_id = (
|
||||
SELECT pending.file_id FROM scanner_inbox_documents AS pending
|
||||
JOIN files ON files.id = pending.file_id AND files.tenant = pending.tenant_id
|
||||
JOIN tenants ON tenants.id = pending.tenant_id
|
||||
WHERE pending.filed_at IS NULL AND files.archived = false
|
||||
AND tenants.locked IS DISTINCT FROM 'maintenance_tenant'
|
||||
AND (pending.suggestion_status = 'pending' OR
|
||||
(pending.suggestion_status = 'processing' AND pending.suggestion_updated_at < now() - interval '5 minutes'))
|
||||
ORDER BY pending.received_at, pending.file_id FOR UPDATE OF pending SKIP LOCKED LIMIT 1
|
||||
) RETURNING job.file_id AS "fileId", job.tenant_id AS "tenantId"`)
|
||||
const job = claimed.rows[0]
|
||||
if (!job) return false
|
||||
const tenantId = Number(job.tenantId)
|
||||
const condition = and(eq(scannerInboxDocuments.fileId, job.fileId), eq(scannerInboxDocuments.tenantId, tenantId),
|
||||
eq(scannerInboxDocuments.suggestionClaim, claim), isNull(scannerInboxDocuments.filedAt))
|
||||
try {
|
||||
const document = await suggestionDocument(tenantId, job.fileId)
|
||||
const { path, name, mimeType, size } = document
|
||||
const result = await analyze({ path, name, mimeType, size }, await filingOptions(tenantId))
|
||||
await suggestionDocument(tenantId, job.fileId)
|
||||
const suggestion = validateFilingSuggestion(result, await filingOptions(tenantId))
|
||||
await db.update(scannerInboxDocuments).set({ suggestionStatus: "ready", suggestion,
|
||||
suggestionMessage: suggestion ? null : result.message || "Keine passende Zuordnung gefunden. Bitte manuell auswählen.",
|
||||
suggestionClaim: null, suggestionUpdatedAt: new Date() }).where(condition)
|
||||
} catch (error: any) {
|
||||
const message = error?.statusCode === 503 ? "Die KI-Erkennung ist noch nicht konfiguriert. Bitte manuell zuordnen oder später erneut prüfen."
|
||||
: "Die KI-Vorbereitung ist fehlgeschlagen. Bitte erneut prüfen oder manuell zuordnen."
|
||||
await db.update(scannerInboxDocuments).set({ suggestionStatus: "failed", suggestion: null, suggestionMessage: message,
|
||||
suggestionClaim: null, suggestionUpdatedAt: new Date() }).where(condition)
|
||||
}
|
||||
return true
|
||||
}
|
||||
const getAssignments = async (tenantId: number, fileId: string) => {
|
||||
await suggestionDocument(tenantId, fileId)
|
||||
const [file] = await db.select().from(files).where(and(eq(files.id, fileId), eq(files.tenant, tenantId), eq(files.archived, false))).limit(1)
|
||||
if (!file) throw scannerError(404, "Dokument nicht gefunden.")
|
||||
return currentAssignments(db, tenantId, file)
|
||||
}
|
||||
const archiveDocument = async (tenantId: number, userId: string, fileId: string) => db.transaction(async tx => {
|
||||
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
|
||||
.where(eq(tenants.id, tenantId)).for("share")
|
||||
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
|
||||
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
|
||||
// Use the same lock order as filing so both actions cannot succeed concurrently.
|
||||
const [entry] = await tx.select({ filedAt: scannerInboxDocuments.filedAt }).from(scannerInboxDocuments)
|
||||
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.fileId, fileId))).for("update")
|
||||
if (!entry) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
|
||||
if (entry.filedAt) throw scannerError(409, "Dieses Dokument wurde bereits abgelegt. Bitte den Eingang aktualisieren.")
|
||||
const [file] = await tx.select({ archived: files.archived }).from(files)
|
||||
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId))).for("update")
|
||||
if (!file) throw scannerError(404, "Dokument nicht gefunden.")
|
||||
if (!file.archived) {
|
||||
await tx.update(files).set({ archived: true, updatedAt: new Date(), updatedBy: userId })
|
||||
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId)))
|
||||
}
|
||||
// Keep the receipt for scanner retries; invalidate any analysis already in flight.
|
||||
await tx.update(scannerInboxDocuments).set({ suggestionClaim: null,
|
||||
suggestionStatus: sql`CASE WHEN ${scannerInboxDocuments.suggestionStatus} = 'processing' THEN 'pending' ELSE ${scannerInboxDocuments.suggestionStatus} END` })
|
||||
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
|
||||
return { id: fileId, archived: true }
|
||||
})
|
||||
const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string, assignments: FileAssignments = {}) => db.transaction(async tx => {
|
||||
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
|
||||
.where(eq(tenants.id, tenantId)).for("share")
|
||||
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
|
||||
@@ -157,11 +230,12 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
||||
const [type] = await tx.select({ id: filetags.id }).from(filetags)
|
||||
.where(and(eq(filetags.id, typeId), eq(filetags.tenant, tenantId), eq(filetags.archived, false))).for("share")
|
||||
if (!type) throw scannerError(400, "Der gewählte Dokumenttyp ist nicht verfügbar.")
|
||||
const assignmentChanges = await validateAssignments(tx, tenantId, assignments)
|
||||
const now = new Date()
|
||||
await tx.update(files).set({ folder: folderId, type: typeId, updatedAt: now, updatedBy: userId })
|
||||
await tx.update(files).set({ ...assignmentChanges, folder: folderId, type: typeId, updatedAt: now, updatedBy: userId })
|
||||
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId)))
|
||||
// Keep the receipt so a scanner retry cannot recreate a document after filing.
|
||||
await tx.update(scannerInboxDocuments).set({ filedAt: now })
|
||||
await tx.update(scannerInboxDocuments).set({ filedAt: now, suggestionClaim: null })
|
||||
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
|
||||
return { id: fileId, folder: folderId, type: typeId }
|
||||
})
|
||||
@@ -225,7 +299,8 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return { connection, credentials, disable, authenticate, find, list, filingOptions, suggestFiling, fileDocument, probe, accept }
|
||||
return { connection, credentials, disable, authenticate, find, list, openCount, filingOptions, getSuggestion, queueSuggestion, processNextSuggestion,
|
||||
getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), archiveDocument, fileDocument, probe, accept }
|
||||
}
|
||||
|
||||
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>
|
||||
|
||||
27
backend/src/modules/scanner-inbox.worker.ts
Normal file
27
backend/src/modules/scanner-inbox.worker.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { createScannerInboxService, type ScannerInboxService } from "./scanner-inbox.service"
|
||||
|
||||
export function startScannerInboxWorker(server: FastifyInstance, service: Pick<ScannerInboxService, "processNextSuggestion"> = createScannerInboxService(server.db)) {
|
||||
let stopped = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let running: Promise<void> | undefined
|
||||
const run = async () => {
|
||||
try {
|
||||
// One analysis per process; database claims coordinate multiple backend instances.
|
||||
while (!stopped && await service.processNextSuggestion()) { /* drain the durable queue */ }
|
||||
} catch {
|
||||
server.log.error("KI-Vorbereitung der Dokumentenablage unterbrochen; erneuter Versuch folgt.")
|
||||
} finally {
|
||||
if (!stopped) {
|
||||
timer = setTimeout(() => { running = run() }, 2000)
|
||||
timer.unref()
|
||||
}
|
||||
}
|
||||
}
|
||||
server.addHook("onReady", async () => { running = run() })
|
||||
server.addHook("onClose", async () => {
|
||||
stopped = true
|
||||
clearTimeout(timer)
|
||||
await running
|
||||
})
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
communicationRoomMembers,
|
||||
communicationRoomReads,
|
||||
communicationRooms,
|
||||
communicationHiddenRooms,
|
||||
notificationsItems,
|
||||
projects,
|
||||
} from "../../db/schema"
|
||||
@@ -88,13 +89,13 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
.onConflictDoNothing()
|
||||
}
|
||||
|
||||
const ensureGeneralRoom = async (tenantId: number, creatorId: string) => {
|
||||
let [room] = await server.db.select().from(communicationRooms)
|
||||
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein")))
|
||||
.limit(1)
|
||||
const ensureGeneralRoom = async (tenantId: number, creatorId: string) => server.db.transaction(async (tx) => {
|
||||
const generalKey = and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein"))
|
||||
let [room] = await tx.select().from(communicationRooms)
|
||||
.where(generalKey).limit(1).for("update")
|
||||
|
||||
if (!room) {
|
||||
const [created] = await server.db.insert(communicationRooms).values({
|
||||
const [created] = await tx.insert(communicationRooms).values({
|
||||
tenantId,
|
||||
key: "allgemein",
|
||||
name: "Allgemeiner Chat",
|
||||
@@ -104,16 +105,28 @@ export default async function communicationRoutes(server: FastifyInstance) {
|
||||
}).onConflictDoNothing().returning()
|
||||
room = created
|
||||
if (!room) {
|
||||
[room] = await server.db.select().from(communicationRooms)
|
||||
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.key, "allgemein")))
|
||||
.limit(1)
|
||||
[room] = await tx.select().from(communicationRooms)
|
||||
.where(generalKey).limit(1).for("update")
|
||||
}
|
||||
}
|
||||
|
||||
if (room.type !== "general" || room.archived) chatError("Allgemeiner Chat ist nicht verfügbar", 409)
|
||||
await addMembers(room.id, (await tenantUsers(tenantId)).map((user) => user.userId))
|
||||
// Old Matrix general-room endpoints omitted the type and persisted its default 'room'.
|
||||
// Only unarchived, unbound tenant rooms may be adopted; a key collision must not grant access.
|
||||
if (!room || !["general", "room"].includes(room.type) || room.archived
|
||||
|| room.entityType !== null || room.entityId !== null || room.entityUuid !== null) {
|
||||
chatError("Allgemeiner Chat ist nicht verfügbar", 409)
|
||||
}
|
||||
if (room.type === "room") {
|
||||
[room] = await tx.update(communicationRooms).set({ type: "general" })
|
||||
.where(eq(communicationRooms.id, room.id)).returning()
|
||||
}
|
||||
const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers)
|
||||
.where(eq(authTenantUsers.tenant_id, tenantId))
|
||||
if (users.length) await tx.insert(communicationRoomMembers)
|
||||
.values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId })))
|
||||
.onConflictDoNothing()
|
||||
return room
|
||||
}
|
||||
})
|
||||
|
||||
const requireRoom = async (req: any, tenantId: number, userId: string, roomKey: string) => {
|
||||
const [row] = await server.db.select({ room: communicationRooms }).from(communicationRooms)
|
||||
@@ -232,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) => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, eq } from "drizzle-orm"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import { authProfiles } from "../../db/schema"
|
||||
import { createScannerInboxService, SCANNER_DAV_PATH, SCANNER_UPLOAD_LIMIT, type ScannerInboxService } from "../modules/scanner-inbox.service"
|
||||
import { assignmentTargets, type FileAssignments } from "../modules/scanner-inbox-assignments"
|
||||
|
||||
export default async function documentInboxRoutes(server: FastifyInstance, options: { service?: ScannerInboxService }) {
|
||||
const service = options.service || createScannerInboxService(server.db)
|
||||
@@ -25,18 +26,35 @@ export default async function documentInboxRoutes(server: FastifyInstance, optio
|
||||
return service.list(req.user.tenant_id!, limit, offset, query.search?.trim() || "")
|
||||
})
|
||||
server.get("/document-inbox/filing-options", async req => service.filingOptions(req.user.tenant_id!))
|
||||
server.get("/document-inbox/count", async req => service.openCount(req.user.tenant_id!))
|
||||
server.get<{ Querystring: { kind: string; search?: string } }>("/document-inbox/assignment-options", {
|
||||
schema: { querystring: { type: "object", required: ["kind"], properties: {
|
||||
kind: { type: "string", enum: Object.keys(assignmentTargets) }, search: { type: "string", maxLength: 200 },
|
||||
} } },
|
||||
}, async req => service.assignmentOptions(req.user.tenant_id!, req.query.kind, req.query.search?.trim() || ""))
|
||||
server.get<{ Params: { id: string } }>("/document-inbox/:id/assignments", {
|
||||
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
|
||||
}, async req => service.getAssignments(req.user.tenant_id!, req.params.id))
|
||||
server.get<{ Params: { id: string } }>("/document-inbox/:id/suggestion", {
|
||||
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
|
||||
}, async req => service.getSuggestion(req.user.tenant_id!, req.params.id))
|
||||
server.post<{ Params: { id: string } }>("/document-inbox/:id/suggestion", {
|
||||
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
|
||||
}, async req => service.suggestFiling(req.user.tenant_id!, req.params.id))
|
||||
server.post<{ Params: { id: string }; Body: { folder: string | null; type: string } }>("/document-inbox/:id/file", {
|
||||
}, async (req, reply) => reply.code(202).send(await service.queueSuggestion(req.user.tenant_id!, req.params.id)))
|
||||
server.post<{ Params: { id: string } }>("/document-inbox/:id/archive", {
|
||||
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
|
||||
}, async req => service.archiveDocument(req.user.tenant_id!, req.user.user_id, req.params.id))
|
||||
server.post<{ Params: { id: string }; Body: { folder: string | null; type: string; assignments?: FileAssignments } }>("/document-inbox/:id/file", {
|
||||
schema: {
|
||||
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
|
||||
body: { type: "object", required: ["folder", "type"], additionalProperties: false, properties: {
|
||||
folder: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
||||
type: { type: "string", format: "uuid" },
|
||||
assignments: { type: "object", additionalProperties: false, properties: Object.fromEntries(Object.keys(assignmentTargets).map(kind =>
|
||||
[kind, { anyOf: [{ type: "null" }, { type: "integer", minimum: 1, maximum: Number.MAX_SAFE_INTEGER }, { type: "string", minLength: 1, maxLength: 36 }] }])) },
|
||||
} },
|
||||
},
|
||||
}, async req => service.fileDocument(req.user.tenant_id!, req.user.user_id, req.params.id, req.body.folder, req.body.type))
|
||||
}, async req => service.fileDocument(req.user.tenant_id!, req.user.user_id, req.params.id, req.body.folder, req.body.type, req.body.assignments))
|
||||
server.get("/document-inbox/scanner", async req => ({
|
||||
connection: await service.connection(req.user.tenant_id!), path: SCANNER_DAV_PATH, maxFileSize: SCANNER_UPLOAD_LIMIT,
|
||||
}))
|
||||
|
||||
@@ -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<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") {
|
||||
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<string, any> = { ...body }
|
||||
delete data.updatedAt
|
||||
delete data.updated_at
|
||||
|
||||
110
backend/tests/chatVisibility.test.ts
Normal file
110
backend/tests/chatVisibility.test.ts
Normal file
@@ -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() }
|
||||
})
|
||||
54
backend/tests/collaborationConfig.test.ts
Normal file
54
backend/tests/collaborationConfig.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { collaborationConfig } from "../src/modules/collaboration/config"
|
||||
|
||||
const deployment = { API_BASE_URL: "https://fedeo.example/backend", ENCRYPTION_KEY: "a".repeat(64) }
|
||||
|
||||
test("existing HTTPS deployment enables federation without additional environment values", () => {
|
||||
const config = collaborationConfig(deployment)
|
||||
assert.equal(config.enabled, true)
|
||||
assert.equal(config.apiUrl, deployment.API_BASE_URL)
|
||||
assert.equal(config.publicUrl, "https://fedeo.example")
|
||||
assert.equal(config.disabledReason, undefined)
|
||||
assert.equal(collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "auto", FEDEO_FEDERATION_URL: "", FEDEO_PUBLIC_URL: "" }).enabled, true)
|
||||
})
|
||||
|
||||
test("custom API paths are retained and instance addresses are canonicalized", () => {
|
||||
const config = collaborationConfig({ ...deployment, API_BASE_URL: "https://FEDEO.example:443/application/api/" })
|
||||
assert.equal(config.apiUrl, "https://fedeo.example/application/api")
|
||||
assert.equal(config.publicUrl, "https://fedeo.example")
|
||||
assert.equal(config.enabled, true)
|
||||
})
|
||||
|
||||
test("explicit opt-out stays disabled even on a correctly configured deployment", () => {
|
||||
const config = collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "false" })
|
||||
assert.equal(config.enabled, false)
|
||||
assert.match(config.disabledReason!, /Betreiber deaktiviert/)
|
||||
})
|
||||
|
||||
test("optional gateway override does not move guest links away from the web application", () => {
|
||||
const config = collaborationConfig({ ...deployment, FEDEO_FEDERATION_URL: "https://gateway.example/federation-api/" })
|
||||
assert.equal(config.apiUrl, "https://gateway.example/federation-api")
|
||||
assert.equal(config.publicUrl, "https://fedeo.example")
|
||||
assert.equal(collaborationConfig({ ...deployment, FEDEO_PUBLIC_URL: "https://portal.example/app/" }).publicUrl, "https://portal.example/app")
|
||||
})
|
||||
|
||||
test("local and incomplete deployments keep local collaboration available without federation", () => {
|
||||
for (const API_BASE_URL of [undefined, "", "invalid", "http://localhost:3100", "https://fedeo.example:8443/backend", "https://user:password@fedeo.example/backend"]) {
|
||||
const config = collaborationConfig({ ...deployment, API_BASE_URL })
|
||||
assert.equal(config.enabled, false, API_BASE_URL)
|
||||
assert.match(config.disabledReason!, /HTTPS-API_BASE_URL/)
|
||||
}
|
||||
for (const ENCRYPTION_KEY of [undefined, "", "invalid", "x".repeat(64)]) {
|
||||
const config = collaborationConfig({ ...deployment, ENCRYPTION_KEY })
|
||||
assert.equal(config.enabled, false)
|
||||
assert.match(config.disabledReason!, /ENCRYPTION_KEY/)
|
||||
}
|
||||
})
|
||||
|
||||
test("forced activation reports invalid configuration instead of silently ignoring it", () => {
|
||||
assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true", API_BASE_URL: "http://localhost" }), /HTTPS/)
|
||||
assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true", ENCRYPTION_KEY: "invalid" }), /ENCRYPTION_KEY/)
|
||||
assert.throws(() => collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "typo" }), /auto, true oder false/)
|
||||
assert.equal(collaborationConfig({ ...deployment, FEDEO_FEDERATION_ENABLED: "true" }).enabled, true)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import assert from "node:assert/strict"
|
||||
import test from "node:test"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import Fastify from "fastify"
|
||||
import communicationRoutes from "../src/routes/communication"
|
||||
import { assertCustomRoomKey } from "../src/modules/communication-access"
|
||||
@@ -10,6 +11,71 @@ test("custom room keys cannot impersonate system rooms", () => {
|
||||
assert.doesNotThrow(() => assertCustomRoomKey("baustelle"))
|
||||
})
|
||||
|
||||
test("general chat accepts legacy room metadata without exposing protected rooms", { skip: !testDatabaseUrl }, async (t) => {
|
||||
const fixture = await collaborationDatabase(), app = Fastify()
|
||||
const user = randomUUID(), other = randomUUID(), legacyRoom = randomUUID(), otherTenantRoom = randomUUID()
|
||||
try {
|
||||
await fixture.pool.query("INSERT INTO tenants VALUES (1,'Legacy tenant',null),(2,'Other tenant',null)")
|
||||
await fixture.pool.query("INSERT INTO auth_users(id,email) VALUES ($1,'user@example.test'),($2,'other@example.test')", [user, other])
|
||||
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(1,$2),(2,$2)", [user, other])
|
||||
// Matrix's general-room endpoints supplied key/name but omitted type, defaulting to 'room'.
|
||||
await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,topic,type,archived)
|
||||
VALUES ($1,1,'allgemein','Allgemeiner Chat','Existing topic','room',false),
|
||||
($2,2,'allgemein','Allgemeiner Chat',null,'room',false)`, [legacyRoom, otherTenantRoom])
|
||||
await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2)", [legacyRoom, other])
|
||||
await fixture.pool.query("INSERT INTO communication_messages(tenant_id,room_id,author_user_id,body) VALUES (1,$1,$2,'Existing history')", [legacyRoom, other])
|
||||
app.decorate("db", fixture.db)
|
||||
app.addHook("preHandler", async (req: any) => { req.user = { tenant_id: 1, user_id: user }; req.hasPermission = () => false })
|
||||
await app.register(communicationRoutes)
|
||||
|
||||
await t.test("concurrent legacy loads preserve the room, history and tenant boundary", async () => {
|
||||
const responses = await Promise.all([app.inject("/communication/chat/rooms"), app.inject("/communication/chat/rooms")])
|
||||
for (const response of responses) {
|
||||
assert.equal(response.statusCode, 200, response.body)
|
||||
assert.equal(response.json().rooms.length, 1)
|
||||
assert.equal(response.json().rooms[0].id, legacyRoom)
|
||||
assert.equal(response.json().rooms[0].type, "general")
|
||||
assert.equal(response.json().rooms[0].topic, "Existing topic")
|
||||
}
|
||||
const history = await app.inject("/communication/chat/rooms/allgemein/messages")
|
||||
assert.equal(history.statusCode, 200, history.body)
|
||||
assert.equal(history.json().messages[0].body, "Existing history")
|
||||
assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_room_members WHERE room_id=$1", [legacyRoom])).rows[0].count, "2")
|
||||
assert.equal((await fixture.pool.query("SELECT type FROM communication_rooms WHERE id=$1", [otherTenantRoom])).rows[0].type, "room")
|
||||
})
|
||||
|
||||
for (const candidate of [
|
||||
{ type: "direct" }, { type: "project" }, { type: "room", entityType: "project", entityId: 42 },
|
||||
{ type: "room", entityId: 42 }, { type: "room", entityUuid: other },
|
||||
{ type: "general", entityType: "project", entityId: 42 },
|
||||
{ type: "room", archived: true }, { type: "general", archived: true },
|
||||
]) await t.test(`protected general-key collision is not converted: ${JSON.stringify(candidate)}`, async () => {
|
||||
const roomId = randomUUID()
|
||||
await fixture.pool.query("UPDATE communication_rooms SET key='preserved-legacy' WHERE id=$1", [legacyRoom])
|
||||
await fixture.pool.query(`INSERT INTO communication_rooms(id,tenant_id,key,name,type,archived,entity_type,entity_id,entity_uuid)
|
||||
VALUES ($1,1,'allgemein','Protected',$2,$3,$4,$5,$6)`,
|
||||
[roomId, candidate.type, candidate.archived || false, candidate.entityType || null, candidate.entityId || null, candidate.entityUuid || null])
|
||||
await fixture.pool.query("INSERT INTO communication_room_members(room_id,user_id) VALUES ($1,$2)", [roomId, other])
|
||||
try {
|
||||
const response = await app.inject("/communication/chat/rooms")
|
||||
assert.equal(response.statusCode, 409, response.body)
|
||||
const stored = (await fixture.pool.query("SELECT type,archived FROM communication_rooms WHERE id=$1", [roomId])).rows[0]
|
||||
assert.equal(stored.type, candidate.type)
|
||||
assert.equal(stored.archived, candidate.archived || false)
|
||||
assert.deepEqual((await fixture.pool.query("SELECT user_id FROM communication_room_members WHERE room_id=$1", [roomId])).rows.map(m => m.user_id), [other])
|
||||
} finally {
|
||||
await fixture.pool.query("DELETE FROM communication_rooms WHERE id=$1", [roomId])
|
||||
}
|
||||
})
|
||||
|
||||
await t.test("concurrent first use creates one general room", async () => {
|
||||
const responses = await Promise.all([app.inject("/communication/chat/rooms"), app.inject("/communication/chat/rooms")])
|
||||
for (const response of responses) assert.equal(response.statusCode, 200, response.body)
|
||||
assert.equal((await fixture.pool.query("SELECT count(*) FROM communication_rooms WHERE tenant_id=1 AND key='allgemein'")).rows[0].count, "1")
|
||||
})
|
||||
} finally { await app.close(); await fixture.close() }
|
||||
})
|
||||
|
||||
test("chat routes protect existing memberships and assigned projects", { skip: !testDatabaseUrl }, async () => {
|
||||
const fixture = await collaborationDatabase()
|
||||
const app = Fastify()
|
||||
|
||||
@@ -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(",")})`)
|
||||
|
||||
@@ -193,10 +193,14 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
CREATE TABLE filetags (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||
CREATE TABLE folders (id uuid PRIMARY KEY, tenant bigint, name text, parent uuid, "standardFiletype" uuid,
|
||||
"standardFiletypeIsOptional" boolean DEFAULT true, archived boolean DEFAULT false);
|
||||
CREATE TABLE auth_profiles (tenant_id bigint, user_id uuid, customer_for_portal bigint);`)
|
||||
CREATE TABLE projects (id bigint PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||
CREATE TABLE customers (id bigint PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||
CREATE TABLE checks (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||
CREATE TABLE auth_profiles (id uuid DEFAULT gen_random_uuid(), tenant_id bigint, user_id uuid, customer_for_portal bigint,
|
||||
full_name text, active boolean DEFAULT true);`)
|
||||
const migrationRoot = path.resolve(__dirname, "../db/migrations")
|
||||
const journal = JSON.parse(await readFile(path.join(migrationRoot, "meta/_journal.json"), "utf8"))
|
||||
const entries = ["0073_scanner_document_inbox", "0074_scanner_inbox_filing"].map(tag => journal.entries.find((item: any) => item.tag === tag))
|
||||
const entries = ["0073_scanner_document_inbox", "0074_scanner_inbox_filing", "0077_scanner_inbox_preparation"].map(tag => journal.entries.find((item: any) => item.tag === tag))
|
||||
await mkdir(path.join(tempMigrations, "meta"))
|
||||
for (const entry of entries) {
|
||||
assert.ok(entry)
|
||||
@@ -213,14 +217,17 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
const analyses: any[] = []
|
||||
let analysisGate: Promise<void> | undefined
|
||||
let analysisStarted: (() => void) | undefined
|
||||
let analysisFailure = false
|
||||
const service = createScannerInboxService(db, {
|
||||
async put(key, body) { stored.set(key, Buffer.from(body)); if (storageFailure) throw new Error("Simulated object store failure") },
|
||||
async remove(key) { stored.delete(key) },
|
||||
}, async (document, candidates) => {
|
||||
analyses.push({ document, candidates })
|
||||
const result = aiResult
|
||||
analysisStarted?.()
|
||||
await analysisGate
|
||||
return aiResult
|
||||
if (analysisFailure) throw new Error("Simulated AI failure")
|
||||
return result
|
||||
})
|
||||
const first = await service.credentials(1)
|
||||
const second = await service.credentials(2)
|
||||
@@ -265,6 +272,8 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
assert.equal(row.tenant, "1")
|
||||
assert.equal(row.mimeType, "application/pdf")
|
||||
assert.equal(row.extracted_text, null)
|
||||
assert.equal((await pool.query("SELECT suggestion_status FROM scanner_inbox_documents WHERE file_id=$1", [row.id])).rows[0].suggestion_status, "pending")
|
||||
assert.equal(analyses.length, 0, "the upload only queues preparation; it does not wait for OCR/AI")
|
||||
assert.deepEqual(stored.get(row.path), pdf)
|
||||
assert.equal((await PDFDocument.load(stored.get(row.path)!)).getPageCount(), 2)
|
||||
const listing = await app.inject({ method: "GET", url: "/api/document-inbox" })
|
||||
@@ -332,17 +341,21 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
[targetFolder, targetType, foreignFolder, archivedFolder])
|
||||
const fileInto = (id: string, folder: string | null = targetFolder, type: string = targetType, headers = {}) =>
|
||||
app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder, type }, headers })
|
||||
const suggest = (id: string, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${id}/suggestion`, headers })
|
||||
const suggest = (id: string, headers = {}) => app.inject({ method: "GET", url: `/api/document-inbox/${id}/suggestion`, headers })
|
||||
const queue = (id: string, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${id}/suggestion`, headers })
|
||||
await t.test("AI analysis is tenant scoped, validates destinations and never files a document", async () => {
|
||||
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||
const before = (await pool.query("SELECT folder, type, extracted_text FROM files WHERE id=$1", [id])).rows[0]
|
||||
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Rechnung erkannt." } }
|
||||
assert.equal((await suggest(id, { "x-test-tenant": "2" })).statusCode, 404)
|
||||
assert.equal((await suggest("invalid")).statusCode, 400)
|
||||
await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId])
|
||||
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
|
||||
assert.equal((await suggest(id)).statusCode, 403)
|
||||
await pool.query("DELETE FROM auth_profiles")
|
||||
assert.equal(analyses.length, 0)
|
||||
assert.equal((await suggest(id)).json().status, "pending")
|
||||
assert.equal(analyses.length, 0, "reading the preview never starts analysis")
|
||||
assert.equal(await service.processNextSuggestion(), true)
|
||||
const result = await suggest(id, { "x-test-admin": "false" })
|
||||
assert.equal(result.statusCode, 200, result.body)
|
||||
assert.deepEqual(result.json().suggestion, aiResult.suggestion)
|
||||
@@ -352,6 +365,8 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
assert.deepEqual((await pool.query("SELECT folder, type, extracted_text FROM files WHERE id=$1", [id])).rows[0], before)
|
||||
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 1)
|
||||
aiResult = { suggestion: { ...aiResult.suggestion!, folderId: foreignFolder } }
|
||||
assert.equal((await queue(id)).statusCode, 202)
|
||||
await service.processNextSuggestion()
|
||||
assert.equal((await suggest(id)).json().suggestion, null)
|
||||
})
|
||||
await t.test("AI results are checked again if destination availability changes during analysis", async () => {
|
||||
@@ -360,15 +375,93 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
let finish!: () => void
|
||||
analysisGate = new Promise(resolve => { finish = resolve })
|
||||
const started = new Promise<void>(resolve => { analysisStarted = resolve })
|
||||
const pending = suggest(id).then(response => response)
|
||||
await queue(id)
|
||||
const pending = service.processNextSuggestion()
|
||||
await started
|
||||
await pool.query("UPDATE folders SET archived=true WHERE id=$1", [targetFolder])
|
||||
finish()
|
||||
assert.equal((await pending).json().suggestion, null)
|
||||
await pending
|
||||
assert.equal((await suggest(id)).json().suggestion, null)
|
||||
await pool.query("UPDATE folders SET archived=false WHERE id=$1", [targetFolder])
|
||||
analysisGate = undefined
|
||||
analysisStarted = undefined
|
||||
})
|
||||
await t.test("prepared results persist, failed jobs wait for explicit retry and expired claims resume", async () => {
|
||||
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||
const otherService = createScannerInboxService(db, undefined, async () => aiResult)
|
||||
const before = analyses.length
|
||||
assert.equal((await otherService.getSuggestion(1, id)).status, "ready")
|
||||
assert.equal(analyses.length, before)
|
||||
await queue(id)
|
||||
analysisFailure = true
|
||||
await service.processNextSuggestion()
|
||||
analysisFailure = false
|
||||
assert.equal((await suggest(id)).json().status, "failed")
|
||||
assert.equal((await suggest(id)).json().suggestion, null)
|
||||
const failedCount = analyses.length
|
||||
await suggest(id)
|
||||
assert.equal(analyses.length, failedCount)
|
||||
await queue(id)
|
||||
await pool.query("UPDATE scanner_inbox_documents SET suggestion_status='processing', suggestion_claim=$2, suggestion_updated_at=now()-interval '6 minutes' WHERE file_id=$1", [id, randomUUID()])
|
||||
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Vorbereitet nach Neustart." } }
|
||||
await otherService.processNextSuggestion()
|
||||
assert.equal((await suggest(id)).json().suggestion.reason, "Vorbereitet nach Neustart.")
|
||||
})
|
||||
await t.test("an expired worker cannot overwrite a newer completed suggestion", async () => {
|
||||
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||
await queue(id)
|
||||
aiResult = { suggestion: { folderId: targetFolder, typeId: targetType, confidence: "high", reason: "Alter Vorschlag." } }
|
||||
let finish!: () => void
|
||||
analysisGate = new Promise(resolve => { finish = resolve })
|
||||
const started = new Promise<void>(resolve => { analysisStarted = resolve })
|
||||
const pending = service.processNextSuggestion()
|
||||
await started
|
||||
await pool.query("UPDATE scanner_inbox_documents SET suggestion_updated_at=now()-interval '6 minutes' WHERE file_id=$1", [id])
|
||||
const otherService = createScannerInboxService(db, undefined, async () => ({ suggestion: {
|
||||
folderId: null, typeId: alternativeType, confidence: "medium", reason: "Neuer Vorschlag.",
|
||||
} }))
|
||||
await otherService.processNextSuggestion()
|
||||
finish()
|
||||
await pending
|
||||
assert.equal((await suggest(id)).json().suggestion.reason, "Neuer Vorschlag.")
|
||||
analysisGate = undefined
|
||||
analysisStarted = undefined
|
||||
})
|
||||
await t.test("entity choices and filing reject foreign, archived and wrong-type IDs atomically", async () => {
|
||||
await pool.query("INSERT INTO projects VALUES (11, 1, 'Umbau', false), (12, 2, 'Fremdes Projekt', false), (13, 1, 'Archiv', true)")
|
||||
await pool.query("INSERT INTO customers VALUES (21, 1, 'Musterkunde', false), (22, 2, 'Fremder Kunde', false)")
|
||||
const checkId = randomUUID()
|
||||
const profileId = randomUUID()
|
||||
await pool.query("INSERT INTO checks VALUES ($1, 1, 'Prüfung', false)", [checkId])
|
||||
await pool.query("INSERT INTO auth_profiles (id, tenant_id, full_name) VALUES ($1, 1, 'Test Mitarbeiter')", [profileId])
|
||||
const choices = await app.inject({ method: "GET", url: "/api/document-inbox/assignment-options?kind=project&search=Umbau" })
|
||||
assert.deepEqual(choices.json(), [{ id: "11", name: "Umbau" }])
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/assignment-options?kind=unknown" })).statusCode, 400)
|
||||
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||
for (const assignments of [{ project: "12" }, { project: "13" }, { project: randomUUID() }, { project: "11", customer: "22" }, { check: 11 }, { authProfile: randomUUID() }]) {
|
||||
const res = await app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder: targetFolder, type: targetType, assignments } })
|
||||
assert.equal(res.statusCode, 400, res.body)
|
||||
}
|
||||
assert.deepEqual((await pool.query("SELECT folder, type, project, customer FROM files WHERE id=$1", [id])).rows[0], { folder: null, type: null, project: null, customer: null })
|
||||
await pool.query("UPDATE files SET project=11 WHERE id=$1", [id])
|
||||
assert.deepEqual((await app.inject({ method: "GET", url: `/api/document-inbox/${id}/assignments` })).json(), [{ kind: "project", id: "11", name: "Umbau", available: true }])
|
||||
assert.equal((await app.inject({ method: "GET", url: `/api/document-inbox/${id}/assignments`, headers: { "x-test-tenant": "2" } })).statusCode, 404)
|
||||
await service.fileDocument(1, userId, id, targetFolder, targetType, { project: null, customer: "21", check: checkId, authProfile: profileId })
|
||||
assert.deepEqual((await pool.query('SELECT project, customer, "check", auth_profile FROM files WHERE id=$1', [id])).rows[0], { project: null, customer: '21', check: checkId, auth_profile: profileId })
|
||||
// Restore the fixture so existing retry/filing tests can exercise this same original document.
|
||||
await pool.query('UPDATE files SET folder=null, type=null, customer=null, "check"=null, auth_profile=null WHERE id=$1', [id])
|
||||
await pool.query("UPDATE scanner_inbox_documents SET filed_at=null WHERE file_id=$1", [id])
|
||||
})
|
||||
await t.test("menu count includes only this tenant's open, unarchived documents", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/api/document-inbox/count" })
|
||||
assert.equal(res.json().total, (await service.list(1, 50, 0, "")).total)
|
||||
const other = await app.inject({ method: "GET", url: "/api/document-inbox/count", headers: { "x-test-tenant": "2" } })
|
||||
assert.equal(other.json().total, 1)
|
||||
const id = (await service.list(1, 50, 0, "failed.pdf")).documents[0].id
|
||||
await pool.query("UPDATE files SET archived=true WHERE id=$1", [id])
|
||||
assert.equal((await service.openCount(1)).total, res.json().total - 1)
|
||||
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
|
||||
})
|
||||
await t.test("filing options and destinations exclude other tenants and archived entries", async () => {
|
||||
const options = await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })
|
||||
assert.equal(options.statusCode, 200)
|
||||
@@ -388,7 +481,7 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||
assert.equal((await fileInto(id)).statusCode, 423)
|
||||
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
|
||||
await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId])
|
||||
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
|
||||
assert.equal((await fileInto(id)).statusCode, 403)
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })).statusCode, 403)
|
||||
await pool.query("DELETE FROM auth_profiles")
|
||||
@@ -434,6 +527,61 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
assert.equal((await fileInto(id)).statusCode, 404)
|
||||
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
|
||||
})
|
||||
await t.test("archiving is tenant scoped, preserves the original and invalidates an ongoing analysis", async () => {
|
||||
assert.equal((await send("archive.pdf")).statusCode, 201)
|
||||
const id = (await service.list(1, 50, 0, "archive.pdf")).documents[0].id
|
||||
const archive = (fileId = id, headers = {}) => app.inject({ method: "POST", url: `/api/document-inbox/${fileId}/archive`, headers })
|
||||
assert.equal((await archive(id, { "x-test-tenant": "2" })).statusCode, 404)
|
||||
assert.equal((await archive(randomUUID())).statusCode, 404)
|
||||
assert.equal((await archive("invalid")).statusCode, 400)
|
||||
const filedId = (await pool.query("SELECT file_id FROM scanner_inbox_documents WHERE tenant_id=1 AND remote_filename='scan.pdf'")).rows[0].file_id
|
||||
assert.equal((await archive(filedId)).statusCode, 409)
|
||||
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||
assert.equal((await archive()).statusCode, 423)
|
||||
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
|
||||
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
|
||||
assert.equal((await archive()).statusCode, 403)
|
||||
await pool.query("DELETE FROM auth_profiles")
|
||||
const before = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
|
||||
const countBefore = (await service.openCount(1)).total
|
||||
const storedBefore = stored.size
|
||||
// Run this document first, then archive it while its provider response is still pending.
|
||||
await pool.query("UPDATE scanner_inbox_documents SET received_at=now()-interval '1 day' WHERE file_id=$1", [id])
|
||||
let finish!: () => void
|
||||
analysisGate = new Promise(resolve => { finish = resolve })
|
||||
const started = new Promise<void>(resolve => { analysisStarted = resolve })
|
||||
const pending = service.processNextSuggestion()
|
||||
await started
|
||||
assert.equal(analyses.at(-1).document.name, "archive.pdf")
|
||||
const result = await archive(id, { "x-test-admin": "false" })
|
||||
finish()
|
||||
await pending
|
||||
analysisGate = undefined
|
||||
analysisStarted = undefined
|
||||
assert.equal(result.statusCode, 200, result.body)
|
||||
assert.deepEqual(result.json(), { id, archived: true })
|
||||
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
|
||||
assert.equal(row.archived, true)
|
||||
assert.equal(row.updated_by, userId)
|
||||
assert.ok(row.updated_at)
|
||||
assert.equal(row.path, before.path)
|
||||
assert.equal(row.folder, before.folder)
|
||||
assert.equal(row.type, before.type)
|
||||
assert.deepEqual(stored.get(row.path), pdf)
|
||||
const receipt = (await pool.query("SELECT filed_at, suggestion_claim, suggestion_status FROM scanner_inbox_documents WHERE file_id=$1", [id])).rows[0]
|
||||
assert.deepEqual(receipt, { filed_at: null, suggestion_claim: null, suggestion_status: "pending" })
|
||||
assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0)
|
||||
assert.equal((await service.openCount(1)).total, countBefore - 1)
|
||||
assert.equal((await suggest(id)).statusCode, 404)
|
||||
assert.equal((await fileInto(id)).statusCode, 404)
|
||||
assert.equal((await archive()).statusCode, 200, "repeated requests remain successful")
|
||||
assert.equal((await pool.query("SELECT updated_at FROM files WHERE id=$1", [id])).rows[0].updated_at.getTime(), row.updated_at.getTime())
|
||||
assert.equal((await send("archive.pdf")).statusCode, 204)
|
||||
assert.equal((await service.list(1, 50, 0, "archive.pdf")).total, 0)
|
||||
assert.equal(stored.size, storedBefore)
|
||||
while (await service.processNextSuggestion()) { /* drain the remaining fixtures */ }
|
||||
assert.equal(analyses.filter(item => item.document.name === "archive.pdf").length, 1)
|
||||
})
|
||||
await t.test("maintenance, portal access and administrator-only settings are enforced", async () => {
|
||||
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||
assert.equal((await send("maintenance.pdf")).statusCode, 423)
|
||||
@@ -444,7 +592,7 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
assert.equal((await app.inject({ method, url, headers: { "x-test-admin": "false" } })).statusCode, 403)
|
||||
}
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox", headers: { "x-test-admin": "false" } })).statusCode, 200)
|
||||
await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId])
|
||||
await pool.query("INSERT INTO auth_profiles (tenant_id, user_id, customer_for_portal) VALUES (1, $1, 123)", [userId])
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox" })).statusCode, 403)
|
||||
await pool.query("DELETE FROM auth_profiles")
|
||||
})
|
||||
|
||||
@@ -125,7 +125,7 @@ services:
|
||||
API_BASE_URL: ${API_BASE_URL}
|
||||
FEDEO_PUBLIC_URL: ${FEDEO_PUBLIC_URL:-}
|
||||
FEDEO_FEDERATION_URL: ${FEDEO_FEDERATION_URL:-}
|
||||
FEDEO_FEDERATION_ENABLED: ${FEDEO_FEDERATION_ENABLED:-false}
|
||||
FEDEO_FEDERATION_ENABLED: ${FEDEO_FEDERATION_ENABLED:-auto}
|
||||
GOCARDLESS_BASE_URL: ${GOCARDLESS_BASE_URL}
|
||||
GOCARDLESS_SECRET_ID: ${GOCARDLESS_SECRET_ID}
|
||||
GOCARDLESS_SECRET_KEY: ${GOCARDLESS_SECRET_KEY}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Konzept: Föderierte Chats und externe Projektteilnehmer
|
||||
|
||||
Stand: 30. September 2026. Das ursprüngliche Zielbild basiert auf Commit `df48c976`. Die Schritte 0, 1a, 1b und ein föderierter Web-Pilot aus Schritt 2 sind inzwischen implementiert (`21fe4723`, `ea641bb4`, `7b5696ed`). Die folgenden Architekturkapitel enthalten weiterhin spätere Ausbaustufen; den tatsächlichen Lieferumfang, die Einrichtung und verbleibende Grenzen beschreibt Abschnitt 15. Es wurden keine produktiven Instanzen verändert oder echten Einladungen versendet.
|
||||
Stand: 2. Oktober 2026. Das ursprüngliche Zielbild basiert auf Commit `df48c976`. Die Schritte 0, 1a, 1b und ein föderierter Web-Pilot aus Schritt 2 sind inzwischen implementiert (`21fe4723`, `ea641bb4`, `7b5696ed`). Die folgenden Architekturkapitel enthalten weiterhin spätere Ausbaustufen; den tatsächlichen Lieferumfang, die Einrichtung und verbleibende Grenzen beschreibt Abschnitt 15. Es wurden keine produktiven Instanzen verändert oder echten Einladungen versendet.
|
||||
|
||||
## 1. Empfehlung
|
||||
|
||||
@@ -285,6 +285,10 @@ 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 → 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.
|
||||
@@ -297,13 +301,15 @@ 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.
|
||||
2. `FEDEO_PUBLIC_URL` auf die öffentliche Webadresse setzen. Für Gastcodes und optional versendete Einladungs-E-Mails muss der bestehende SMTP-Versand funktionieren. `ENCRYPTION_KEY` muss wie für die übrige Backend-Verschlüsselung ein stabiler 64-stelliger Hex-Schlüssel sein.
|
||||
3. Für den instanzübergreifenden Pilotbetrieb `FEDEO_FEDERATION_URL` auf die kanonische öffentliche HTTPS-API-Adresse setzen, einschließlich des Reverse-Proxy-Präfixes, etwa `https://fedeo.example/backend`. Anschließend `FEDEO_FEDERATION_ENABLED=true` setzen. Die Variablen sind in `.env.example` und `docker-compose.selfhost.yml` aufgenommen. Föderation ist standardmäßig deaktiviert; lokale Unterräume und Gäste funktionieren unabhängig davon.
|
||||
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 <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.
|
||||
5. In beiden beteiligten Mandanten die jeweils andere API-Adresse unter **Partnerinstanzen** freigeben. Discovery ermittelt Instanz-ID und öffentlichen Schlüssel über HTTPS. Die erste Freigabe pinnt diese Identität. Ein später abweichender Schlüssel wird abgelehnt und braucht eine gesonderte Betreiberprüfung; eine automatische Schlüsselrotation ist noch nicht vorhanden.
|
||||
6. Persönliche Kontaktadresse des Empfängers verwenden, gezielt in einen Unterraum einladen und beim Empfänger annehmen. Für einen ersten Pilotversuch anschließend Rechteänderung, Widerruf und eine vorübergehend unterbrochene Verbindung prüfen.
|
||||
|
||||
Der normale Selfhost-Updater übernimmt die Compose-Datei und Migrationen aus dem Zielrelease; ein manuelles Nachtragen der Föderationsvariablen ist bei verwalteten Installationen nicht erforderlich. Die vorhandene `.env` wird bis auf die Release-Version beibehalten. Eine darin oder in der Override-Datei gesetzte Abschaltung bleibt wirksam. Wurde `FEDEO_FEDERATION_ENABLED=false` aus der früheren Beispielkonfiguration übernommen, muss dieser Wert einmal entfernt oder auf `auto` gesetzt werden, wenn Föderation gewünscht ist. Die automatische Bereitstellung gibt keine Partner oder vorhandenen Chats frei; dafür bleiben die gegenseitige Mandantenfreigabe und explizite Einladung erforderlich.
|
||||
|
||||
Das feste Signaturprofil verwendet Ed25519 mit HTTP Message Signatures und Content-Digest. Signiert werden Methode, vollständige Zieladresse, Inhaltstyp, Inhaltsdigest und Herkunft; Zeitfenster und gespeicherte Nonces verhindern Replay. Das RPC-Protokoll ist Version 1 und ausschließlich für diese FEDEO-Implementierung bestimmt. Es ersetzt weder Matrix-Kompatibilität noch Ende-zu-Ende-Verschlüsselung.
|
||||
|
||||
### Speicherung, Betrieb und Wiederaufnahme
|
||||
@@ -329,3 +335,5 @@ COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collab
|
||||
Jeder Datenbanktest erzeugt ein eigenes Schema und räumt es anschließend auf. Ohne gesetzte Testadresse werden die Datenbanktests bewusst übersprungen; der Datenbankname wird vor dem Anlegen geprüft. Backend-Typprüfung und Nuxt-Produktionsbuild waren erfolgreich, die abschließenden Vue-Komponenten lassen sich kompilieren. Eine visuelle Browserabnahme war in der Arbeitsumgebung mangels verbundenem Browser nicht möglich.
|
||||
|
||||
Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, Push-Benachrichtigungen, Moderation, feinere Kontingente sowie Schlüsselrotation und abgestimmte Wiederherstellung. Dateifreigaben, Aufgabenfreigaben und E2EE sind weiterhin Erweiterungen. Der hier implementierte Stand ist ein nutzbarer Web-Pilot für Chats und ausdrücklich begrenzte Projektzusammenarbeit; ein produktiver Rollout ist damit noch nicht erfolgt.
|
||||
|
||||
Die automatische Einrichtung wurde zusätzlich mit sechs Konfigurationstests und 58 Updater-Tests geprüft. Die Update-Regressionen verwenden echte Compose-Auswertung mit simuliertem Containerbetrieb und prüfen sowohl Installationen ohne Föderationsvariablen als auch den Erhalt einer ausdrücklichen Abschaltung und eigener URL-Vorgaben.
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
# Epson-Scanner und Dokumentenablage
|
||||
|
||||
Unter **Organisation → Dokumentenablage** erscheinen noch nicht abgelegte Scanner-Dateien des aktiven Mandanten, neueste zuerst. Die Seite aktualisiert sich alle zehn Sekunden; Suche, Vorschau und Herunterladen sind verfügbar. Jede übertragene Datei wird unverändert als ein Dokument gespeichert. Ein mehrseitiges PDF bleibt ein Dokument. Der Empfang führt keine Texterkennung, Seitentrennung oder Rechnungsverarbeitung aus.
|
||||
Unter **Organisation → Dokumentenablage** erscheinen noch nicht abgelegte Scanner-Dateien des aktiven Mandanten, neueste zuerst. Die Seite aktualisiert sich alle zehn Sekunden; Suche, Vorschau und Herunterladen sind verfügbar. Ein Badge am Menüeintrag zählt die offenen, nicht archivierten Dokumente unabhängig von der aktuellen Suche. Es aktualisiert sich alle zehn Sekunden und unmittelbar nach dem Ablegen. Jede übertragene Datei wird unverändert als ein Dokument gespeichert. Ein mehrseitiges PDF bleibt ein Dokument. Nach dem erfolgreichen Empfang wird die KI-Zuordnung im Hintergrund vorbereitet; der Scanner muss darauf nicht warten. Seitentrennung und Rechnungsverarbeitung werden nicht ausgelöst.
|
||||
|
||||
## In Dateien ablegen
|
||||
|
||||
1. Auf den Dateinamen, **Vorschau** oder **Ablegen** klicken. Das Modal zeigt das Original und rechts die Zuordnung.
|
||||
2. Beim Öffnen wird ein KI-Vorschlag für Ordner und Dokumenttyp aus dem Dokumentinhalt angefordert. Ein gültiger Vorschlag füllt die Auswahlfelder vor und zeigt eine kurze Begründung. Bereits manuell geänderte Werte werden nicht überschrieben. **KI-Vorschlag einsetzen** übernimmt ihn bei Bedarf wieder in die Felder; **Erneut prüfen** fordert eine neue Analyse an.
|
||||
2. Das Modal lädt den bereits vorbereiteten KI-Vorschlag für Ordner und Dokumenttyp. Läuft die Vorbereitung noch, zeigt es den Fortschritt und lädt das Ergebnis automatisch nach. Ein gültiger Vorschlag füllt die Auswahlfelder vor und zeigt eine kurze Begründung. Bereits manuell geänderte Werte werden nicht überschrieben. **KI-Vorschlag einsetzen** übernimmt ihn bei Bedarf wieder in die Felder; **Erneut prüfen** stellt eine abgeschlossene oder fehlgeschlagene Analyse erneut in die Warteschlange.
|
||||
3. Den vorgeschlagenen vorhandenen FEDEO-Dateiordner oder **Dateien (Hauptebene)** und **Dokumenttyp** prüfen und bei Bedarf ändern. Unterordner erscheinen mit ihrem vollständigen Pfad. Neue Typen lassen sich unter **Stammdaten → Dateitypen** anlegen. Ein vorgeschriebener Ordnertyp ist nicht änderbar.
|
||||
4. Erst **Übernehmen und ablegen** speichert Ordner und Typ am bestehenden Original. Danach verschwindet das Dokument aus dem Eingang und ist unter **Dateien** im gewählten Ordner verfügbar. Es wird keine Kopie erzeugt und keine Rechnungsverarbeitung ausgelöst.
|
||||
4. Optional unter **Zuordnungen** einen Bereich und einen Eintrag auswählen und mit **Zuordnung hinzufügen** vormerken. Wie auf der Dateiseite stehen Projekt, Kunde, Lieferant, Fahrzeug, Objekt, Vertrag, Produkt, Ausgangsbeleg, Eingangsrechnung, Inventarartikel, Überprüfung und Mitarbeiter zur Verfügung. Mehrere Bereiche können gleichzeitig zugeordnet werden, jeweils ein Eintrag pro Bereich. Die Einträge lassen sich nach Namen suchen; bestehende Zuordnungen werden angezeigt und können entfernt werden.
|
||||
5. Erst **Übernehmen und ablegen** speichert Ordner, Typ und vorgemerkte Zuordnungen gemeinsam am bestehenden Original. Danach verschwindet das Dokument aus dem Eingang und ist unter **Dateien** im gewählten Ordner und mit den gewählten Verknüpfungen verfügbar. Es wird keine Kopie erzeugt und keine Rechnungsverarbeitung ausgelöst.
|
||||
|
||||
Nur aktive Ordner und Typen desselben Mandanten sind zulässig. Bei einem Fehler bleibt das Dokument im Eingang. Ein erneuter Scanner-Upload mit demselben Namen und Inhalt bestätigt weiterhin den ursprünglichen Empfang, ohne einen neuen Eingangseintrag anzulegen.
|
||||
Nur aktive Ordner, Typen und Zuordnungsziele desselben Mandanten sind zulässig. Mitarbeiter müssen aktiv und interne Benutzer sein. Bei einem Fehler bleibt das Dokument im Eingang; auch die übrigen Änderungen werden nicht teilweise gespeichert. Ein erneuter Scanner-Upload mit demselben Namen und Inhalt bestätigt weiterhin den ursprünglichen Empfang, ohne einen neuen Eingangseintrag oder Analyseauftrag anzulegen.
|
||||
|
||||
### KI-Vorschläge
|
||||
## Archivieren
|
||||
|
||||
**Archivieren** steht sowohl in jeder Listenzeile als auch im Vorschaufenster zur Verfügung. Nach Bestätigung wird die bestehende Datei als archiviert markiert, ohne sie zu löschen. Ordner und Dokumenttyp müssen dafür nicht ausgewählt sein; ungespeicherte Zuordnungen aus der Vorschau werden nicht übernommen. Das Dokument verschwindet aus der Ablage und der Menüzähler wird sofort aktualisiert. Bei einem Fehler bleibt es sichtbar und der Vorgang kann erneut versucht werden.
|
||||
|
||||
Das Original und sein Empfangseintrag bleiben erhalten. Eine identische Wiederholung durch den Scanner aktiviert die Datei nicht erneut. Wartende KI-Aufträge werden für archivierte Dateien übersprungen und Ergebnisse bereits laufender Analysen verworfen. Die Änderung hält Zeitpunkt und Benutzer fest. Bereits unter Dateien abgelegte Dokumente können nicht mehr über die Ablage archiviert werden.
|
||||
|
||||
## KI-Vorschläge
|
||||
|
||||
Die Analyse nutzt die bestehende zentrale FEDEO-KI-Anbindung oder den konfigurierten `OPENAI_API_KEY`, entsprechend den E-Mail-Zuordnungsvorschlägen. Die Antwort verwendet [Structured Outputs](https://developers.openai.com/api/docs/guides/structured-outputs) und `store: false`. Dokumentinhalt, Dateiname und Kandidatennamen gelten als Daten, nicht als Anweisungen. Das Backend prüft vorgeschlagene IDs und verpflichtende Ordnertypen gegen die aktiven Stammdaten des Mandanten, auch noch einmal nach der Analyse. Der Scanner-Zugang kann diese API nicht nutzen.
|
||||
|
||||
Für die Analyse werden höchstens die ersten fünf PDF-Seiten bzw. die erste Bildseite lokal gelesen (Poppler/Tesseract; JPEG, PNG und TIFF werden für die OCR aufbereitet). Diese Werkzeuge sind im Backend-Image enthalten. Es werden maximal 24.000 Textzeichen und eine begrenzte Auswahl passender Ordner und Typen an die KI geschickt. Die Analyse ist auf Dateien bis 25 MiB begrenzt; die Texterkennung und die einzelnen KI-Anfragen haben Zeitlimits von jeweils 45 Sekunden. Fehlen lesbarer Text, passende Stammdaten oder eine KI-Konfiguration, bleibt die manuelle Zuordnung verfügbar.
|
||||
|
||||
Vorschläge und erkannter Text verändern weder das Original noch dessen Klassifizierung. Die Vorschau hält Ergebnisse während des Seitenbesuchs im Speicher, um erneute Analysen beim Wiederöffnen zu vermeiden; ein Mandantenwechsel verwirft sie. Gleichzeitige Analysen desselben Dokuments werden im Backend zusammengefasst, maximal vier Analysen laufen gleichzeitig pro Backend-Prozess. Die fachliche Qualität der KI-Vorschläge sollte bei der Inbetriebnahme mit eigenen Beispieldokumenten geprüft werden.
|
||||
Vorschläge und erkannter Text verändern weder das Original noch dessen Klassifizierung. Der Auftrag entsteht zusammen mit dem Eingangseintrag in der Datenbank. Das Backend verarbeitet offene Aufträge automatisch, auch ohne geöffnete Dokumentenablage. Vorschlag, Status und eine gegebenenfalls nötige Fehlermeldung werden dauerhaft am Eingangseintrag gespeichert; beim Wiederöffnen wird keine neue Analyse ausgelöst. Erkannter Volltext wird hierfür nicht gespeichert. Vorhandene offene Dokumente werden nach der Migration ebenfalls vorbereitet.
|
||||
|
||||
Pro Backend-Prozess läuft eine Analyse gleichzeitig. Datenbanksperren und Auftragskennungen koordinieren mehrere Instanzen. Neue Aufträge werden spätestens beim nächsten Durchlauf nach zwei Sekunden berücksichtigt, wenn kein älterer Auftrag mehr läuft. Nach einem Prozessabbruch werden seit fünf Minuten nicht abgeschlossene Aufträge erneut übernommen; verspätete Ergebnisse eines alten Auftrags können das neuere Ergebnis nicht überschreiben. Abgelegte, archivierte und Dokumente von Mandanten im Wartungsmodus werden nicht neu gestartet. Fehlgeschlagene Analysen lassen sich im Modal erneut anfordern; die manuelle Ablage bleibt möglich. Die fachliche Qualität der KI-Vorschläge sollte bei der Inbetriebnahme mit eigenen Beispieldokumenten geprüft werden.
|
||||
|
||||
## Einrichtung
|
||||
|
||||
@@ -36,7 +45,7 @@ Nach der Dateinamenprüfung sendet der ES-580W zunächst einen leeren `PUT` mit
|
||||
|
||||
## Schnittstelle und Betrieb
|
||||
|
||||
- Datenbankmigrationen `0073_scanner_document_inbox` und `0074_scanner_inbox_filing` werden über die reguläre Drizzle-Migration eingespielt. Keine neuen Umgebungsvariablen oder zusätzlichen Dienste sind erforderlich; der vorhandene S3-kompatible Dateispeicher wird genutzt.
|
||||
- Datenbankmigrationen `0073_scanner_document_inbox`, `0074_scanner_inbox_filing` und `0077_scanner_inbox_preparation` werden über die reguläre Drizzle-Migration eingespielt. Keine neuen Umgebungsvariablen oder zusätzlichen Dienste sind erforderlich; der vorhandene S3-kompatible Dateispeicher wird genutzt. Die Hintergrundverarbeitung startet mit dem Backend.
|
||||
- Der Scanner-Endpunkt liegt außerhalb der Browser-JWT-Anmeldung unter `/scanner-inbox/` und verwendet HTTP Basic mit eigenen zufälligen Zugangsdaten. Ausschließlich über HTTPS veröffentlichen. Passwörter werden nur als SHA-256-Hash gespeichert.
|
||||
- Unterstützt werden `OPTIONS`, `PROPFIND` (Depth 0/1), `HEAD` und `PUT /scanner-inbox/<Dateiname>`. Ein authentifiziertes `GET` auf den Empfangsordner (mit oder ohne abschließenden Schrägstrich) liefert für die Epson-Vorprüfung `200` mit leerem Inhalt. Dokumente lassen sich damit nicht herunterladen. Der Empfangsordner existiert bereits; Unterordner, Lesen von Dateien, Löschen, Umbenennen und Überschreiben über den Scanner-Zugang sind nicht vorgesehen. Dies ist ein begrenzter WebDAV-Upload-Eingang, kein allgemeines Netzlaufwerk. Ein PROPFIND des Ordners gibt keine Dokumentenliste preis.
|
||||
- `PUT` enthält genau eine vollständige Datei als Rohdaten, ohne Multipart-Formular. PDF, JPEG, PNG und TIFF werden anhand von Dateiendung und Signatur geprüft, maximal 100 MiB. Keine Prüfung der gesamten Dokumentstruktur auf Beschädigungen.
|
||||
@@ -44,8 +53,10 @@ Nach der Dateinamenprüfung sendet der ES-580W zunächst einen leeren `PUT` mit
|
||||
- Fehlende/ungültige/deaktivierte Zugangsdaten: `401`; ungültiger Pfad oder leerer Upload ohne explizite Schreibprobe (`Content-Length: 0`): `400`; zu groß: `413`; falsches Format: `415`; Mandantenwartung: `423`. Bei Speicherfehlern gibt es keine Erfolgsbestätigung und keinen unvollständigen Ablageeintrag. Der Scanner kann erneut senden.
|
||||
- Der Reverse Proxy muss die genannten HTTP-Methoden, den Authorization-Header und Uploads bis 100 MiB zulassen. Bei einem abgeschnittenen Pfadpräfix sollte er `X-Forwarded-Prefix` setzen (Traefik StripPrefix erledigt dies). HTTPS endet üblicherweise am bestehenden Proxy.
|
||||
- Die Browser-API unter `/api/document-inbox` erfordert die normale FEDEO-Anmeldung und einen aktiven Mandanten. Portalbenutzer sind ausgeschlossen. Scanner-Zugänge verwalten nur Administratoren. Die Originale sind außerdem über die bestehende Dateiverwaltung zugänglich.
|
||||
- `GET /api/document-inbox/filing-options` liefert verfügbare Ordner und Typen. `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>" }` klassifiziert das Original und markiert den Eingang atomar als abgelegt. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer.
|
||||
- `POST /api/document-inbox/:id/suggestion` liefert `{ "suggestion": { "folderId": "<UUID oder null>", "typeId": "<UUID>", "confidence": "high|medium", "reason": "…" } }` oder einen leeren Vorschlag mit Erklärung. Nur offene, nicht archivierte Dokumente des aktiven Mandanten sind analysierbar. Die Anfrage legt nichts ab; dafür bleibt der separate `/file`-Aufruf erforderlich.
|
||||
- `GET /api/document-inbox/count` liefert `{ "total": 3 }` für den aktiven Mandanten. `GET /api/document-inbox/filing-options` liefert verfügbare Ordner, Typen und `assignmentKinds`. `GET /api/document-inbox/assignment-options?kind=project&search=Beispiel` liefert bis zu 100 passende Ziele mit `id` und `name`; `GET /api/document-inbox/:id/assignments` die bereits bestehenden Verknüpfungen des Dokuments.
|
||||
- `POST /api/document-inbox/:id/file` mit JSON `{ "folder": "<Ordner-UUID oder null>", "type": "<Dateityp-UUID>", "assignments": { "project": 123, "customer": 456 } }` klassifiziert das Original, speichert die optionalen Verknüpfungen und markiert den Eingang atomar als abgelegt. Weggelassene Zuordnungen bleiben erhalten; JSON-`null` entfernt eine vorhandene Zuordnung. Die Schlüssel entsprechen den Dateifeldern; `check` und `authProfile` verwenden UUIDs, die übrigen Bereiche numerische IDs. Für die Hauptebene ist `folder` als JSON-`null` zu senden. Ein bereits abgelegtes Dokument ergibt `409`; zwischenzeitlich archivierte oder fremde Ziele werden abgewiesen. Dateiänderungen enthalten Zeitpunkt und Benutzer.
|
||||
- `POST /api/document-inbox/:id/archive` archiviert eine Eingangsdatei des aktiven Mandanten ohne weitere Pflichtangaben und antwortet mit `{ "id": "<Datei-UUID>", "archived": true }`. Wiederholte Aufrufe sind erfolgreich, ohne den ursprünglichen Archivierungszeitpunkt zu ändern. Bereits abgelegte Dokumente ergeben `409`, fremde oder nicht im Eingang registrierte Dateien `404` und Mandantenwartung `423`. Archivieren und Ablegen sind gegeneinander abgesichert.
|
||||
- `GET /api/document-inbox/:id/suggestion` liest den gespeicherten Status `pending`, `processing`, `ready` oder `failed` und liefert bei Erfolg beispielsweise `{ "status": "ready", "suggestion": { "folderId": "<UUID oder null>", "typeId": "<UUID>", "confidence": "high|medium", "reason": "…" } }`. Ohne passende Zuordnung ist `suggestion` gleich `null` und `message` erklärt den Grund. `POST /api/document-inbox/:id/suggestion` fordert eine erneute Vorbereitung an und antwortet mit `202` und demselben Statusformat, ohne auf die Analyse zu warten. Ein bereits wartender oder laufender Auftrag wird nicht dupliziert. Nur offene, nicht archivierte Dokumente des aktiven Mandanten sind zugänglich. Diese Anfragen legen nichts ab; dafür bleibt der separate `/file`-Aufruf erforderlich.
|
||||
|
||||
## Lokaler Funktionstest
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
|
||||
Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben.
|
||||
|
||||
Für die Chat-Föderation übernimmt der Updater die neuen Backend-Einträge aus der Release-Compose-Datei automatisch. Zusätzliche Einträge in der bestehenden `.env` sind nicht nötig: Die Föderationsadresse folgt `API_BASE_URL`, Gastlinks verwenden deren Ursprung. Der Standard `FEDEO_FEDERATION_ENABLED=auto` aktiviert die Funktion bei gültiger HTTPS-Adresse auf Port 443 und gültigem `ENCRYPTION_KEY`. Ein vorhandenes `false` und eigene URL-Vorgaben bleiben erhalten. Partnerfreigaben und Einladungen werden weiterhin ausdrücklich je Mandant erteilt. Details stehen in der [Föderationsanleitung](chat-foederation-konzept.md#15-implementierter-stand-und-pilotbetrieb).
|
||||
|
||||
Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die FEDEO-Dienste des jeweiligen Speicherproviders enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Bereits stillgelegte Matrix-Container bleiben erhalten und ihre lokalen Bind-Verzeichnisse werden weiter gesichert. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf.
|
||||
|
||||
Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen.
|
||||
@@ -222,6 +224,42 @@ Aufträge liegen dauerhaft und root-lesbar unter `.updates/service/`. Nach einem
|
||||
7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht.
|
||||
8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen.
|
||||
9. Stack mit `up --wait` starten. Backend-/Frontend-Image-IDs und Healthchecks sowie öffentlich gemeldete Version und Commit prüfen. Erst dann den Release als installiert speichern.
|
||||
10. Nach erfolgreichem Abschluss die **zwei neuesten Sicherungen erfolgreicher Updates** behalten und ältere reguläre Update-Sicherungen entfernen. Beide behaltenen Sicherungen werden zuvor vollständig gegen ihre SHA-256-Manifeste geprüft. Die Sicherungen enthalten jeweils den Stand **vor** dem zugehörigen Update. Auftragsprotokolle und Release-Dateien bleiben erhalten; `backup-pruned.json` dokumentiert entfernte Sicherungen. Fehlgeschlagene, unterbrochene und manuell wiederhergestellte Aufträge sowie separate Speichermigrationssicherungen werden nicht automatisch bereinigt.
|
||||
|
||||
### Backup-Aufbewahrung und bereits belegte Hosts
|
||||
|
||||
Die automatische Bereinigung läuft unter derselben Sperre wie das Update und erst
|
||||
nach erfolgreicher öffentlicher Funktionsprüfung. Eine unvollständige oder beschädigte
|
||||
aufzubewahrende Sicherung verhindert das Löschen älterer Sicherungen. Links, fremde
|
||||
Dateien, eingehängte Verzeichnisse und von Containern verwendete Backup-Pfade werden
|
||||
nicht gelöscht. Docker-Images, Container, Volumes und aktive Daten gehören nicht zu
|
||||
dieser Bereinigung.
|
||||
|
||||
Ein Fehler bei der Bereinigung stoppt die aktualisierte Anwendung nicht und macht
|
||||
ein erfolgreiches Update nicht nachträglich zum Wiederherstellungsfall. Das
|
||||
Updateprotokoll enthält eine Meldung; `.updates/backup-retention.json` dokumentiert
|
||||
das Ergebnis. Eine unterbrochene Löschung kann bei der nächsten Bereinigung anhand
|
||||
ihres Löschprotokolls fortgesetzt werden. Die zwei behaltenen Generationen werden
|
||||
dabei erneut geprüft.
|
||||
|
||||
Für bestehende Hosts, deren Platte bereits durch alte Sicherungen belegt ist,
|
||||
die neuen Host-Dienstdateien installieren und zunächst die Vorschau ausführen:
|
||||
|
||||
```bash
|
||||
sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py prune-backups --directory /opt/fedeo --dry-run
|
||||
sudo python3 /usr/local/lib/fedeo-updater/selfhost-update.py prune-backups --directory /opt/fedeo
|
||||
```
|
||||
|
||||
Dieser Befehl verwendet dieselbe Aufbewahrungsregel und Prüfsummenprüfung, benötigt
|
||||
keinen Image-Download und verändert den aktuellen Update-Status nicht. Laufende
|
||||
Aufträge und offene Wiederherstellungen blockieren ihn. Der letzte erfolgreiche
|
||||
Updateauftrag muss zum installierten Release und zur aktuellen Compose-Datei passen.
|
||||
`check` und fehlgeschlagene Updates löschen keine Sicherungen, auch nicht bei
|
||||
Platzmangel. Für jedes neue Update muss weiterhin Platz für neue Images und eine
|
||||
zusätzliche vollständige Sicherung verfügbar sein; die automatische Aufbewahrung
|
||||
ersetzt keine externe Datensicherung. Der Host-Updater wird durch ein Anwendungsupdate
|
||||
nicht selbst ersetzt: bestehende andere Instanzen benötigen ebenfalls die neuen
|
||||
Host-Dienstdateien.
|
||||
|
||||
Status und Phasen werden atomar in `.updates/status.json` und pro Auftrag in `status.json` gespeichert. `events.jsonl` enthält den Verlauf, `commands.log` Fehlermeldungen der Werkzeuge. Das Verzeichnis hat Modus `0700`; Status- und Konfigurationsdateien werden mit `0600` angelegt. Backups und Werkzeugausgaben enthalten vertrauliche Daten und dürfen nicht ungefiltert an eine Oberfläche ausgeliefert werden. Backups werden nicht automatisch gelöscht.
|
||||
|
||||
@@ -264,7 +302,8 @@ cd ../frontend && node --test tests/selfhost-updates.test.cjs
|
||||
Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich.
|
||||
|
||||
`python3 -B scripts/update/tests/host_storage_integration.py` prüft zusätzlich
|
||||
MinIO-Migration, Speicherübernahme, zwei Anwendungsupdates und Restore der
|
||||
MinIO-Migration, Speicherübernahme, drei Anwendungsupdates, automatische Aufbewahrung
|
||||
von zwei Sicherungsgenerationen und Restore der
|
||||
regulären SeaweedFS-Updatesicherung mit echten Docker-Containern. Anwendung und
|
||||
Release-/HTTP-Antworten sind isolierte Testdaten. Benötigte Images und Aufbau
|
||||
stehen in `scripts/storage/README.md`. Die fachliche Abnahme des veröffentlichten
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' } })
|
||||
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 emit = defineEmits(['changed', 'unauthorized'])
|
||||
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)
|
||||
@@ -95,9 +95,9 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
<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'">
|
||||
<header class="border-b border-gray-200 p-4 dark:border-gray-700">
|
||||
<h2 class="text-lg font-semibold">{{ room?.name || 'Unterraum' }}</h2>
|
||||
<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>
|
||||
<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>
|
||||
</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.description" class="mt-1 whitespace-pre-wrap">{{ room.overview.description }}</p>
|
||||
</div>
|
||||
<div ref="viewport" class="max-h-[60vh] min-h-[18rem] flex-1 space-y-3 overflow-y-auto p-4" aria-live="polite">
|
||||
<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">
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,7 @@ const props = defineProps({
|
||||
|
||||
const route = useRoute()
|
||||
const auth = useAuthStore()
|
||||
const { count: inboxCount } = useDocumentInboxCount()
|
||||
const { has } = usePermission()
|
||||
const tenantExtraModules = computed(() => {
|
||||
const modules = auth.activeTenantData?.extraModules
|
||||
@@ -55,7 +56,8 @@ const links = computed(() => {
|
||||
featureEnabled("files") ? {
|
||||
label: "Dokumentenablage",
|
||||
to: "/organisation/dokumentenablage",
|
||||
icon: "i-heroicons-inbox-arrow-down"
|
||||
icon: "i-heroicons-inbox-arrow-down",
|
||||
badge: inboxCount.value > 0 ? String(inboxCount.value) : undefined,
|
||||
} : null,
|
||||
featureEnabled("files") ? {
|
||||
label: "Dateien",
|
||||
|
||||
31
frontend/composables/useDocumentInboxCount.ts
Normal file
31
frontend/composables/useDocumentInboxCount.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createSharedComposable } from "@vueuse/core"
|
||||
|
||||
export const useDocumentInboxCount = createSharedComposable(() => {
|
||||
const auth = useAuthStore()
|
||||
const { $api } = useNuxtApp()
|
||||
const count = ref(0)
|
||||
const scope = computed(() => auth.user?.id && auth.activeTenant && !auth.profile?.customer_for_portal && auth.activeTenantData?.features?.files !== false
|
||||
? `${auth.user.id}:${auth.activeTenant}` : "")
|
||||
let version = 0
|
||||
let disposed = false
|
||||
let controller: AbortController | undefined
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
const refresh = async () => {
|
||||
const request = ++version
|
||||
clearTimeout(timer)
|
||||
controller?.abort()
|
||||
if (!scope.value || disposed) { count.value = 0; return }
|
||||
controller = new AbortController()
|
||||
try {
|
||||
const result = await $api<{ total: number }>("/api/document-inbox/count", { signal: controller.signal })
|
||||
if (!disposed && request === version) count.value = Math.max(0, Number(result.total) || 0)
|
||||
} catch {
|
||||
if (request === version) count.value = 0
|
||||
} finally {
|
||||
if (!disposed && request === version && scope.value) timer = setTimeout(refresh, 10000)
|
||||
}
|
||||
}
|
||||
watch(scope, () => { count.value = 0; void refresh() }, { immediate: true, flush: "sync" })
|
||||
onScopeDispose(() => { disposed = true; version++; controller?.abort(); clearTimeout(timer) })
|
||||
return { count, refresh }
|
||||
})
|
||||
@@ -1,219 +1,274 @@
|
||||
<script setup>
|
||||
const toast = useToast()
|
||||
const { $api } = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const baseRooms = ref([])
|
||||
const projectRooms = ref([])
|
||||
const directRooms = ref([])
|
||||
const unreadRooms = ref({})
|
||||
const messages = ref([])
|
||||
const members = ref([])
|
||||
const draft = ref("")
|
||||
const activeRoomKey = ref(typeof route.query.room === "string" ? route.query.room : "allgemein")
|
||||
const loading = ref(true)
|
||||
const roomLoading = ref(false)
|
||||
const sending = ref(false)
|
||||
const creating = ref(false)
|
||||
const showRoomForm = ref(false)
|
||||
const messagesViewport = ref(null)
|
||||
const roomForm = reactive({ name: "", key: "", topic: "" })
|
||||
let pollTimer = null
|
||||
let pollBusy = false
|
||||
let syncAfterId = 0
|
||||
|
||||
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 baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
|
||||
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
|
||||
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref('')
|
||||
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
|
||||
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)
|
||||
let pollTimer, listTimer, pollBusy = false, mounted = false, disposed = false
|
||||
let scopeVersion = 0, roomVersion = 0, collaborationRequest = 0, nativeListRequest = 0, syncAfterId = 0
|
||||
const currentScope = version => !disposed && version === scopeVersion
|
||||
const selection = () => ({ scope: scopeVersion, version: roomVersion, key: activeRoomKey.value })
|
||||
const currentSelection = request => currentScope(request.scope) && request.version === roomVersion && request.key === activeRoomKey.value
|
||||
const errorMessage = error => error?.data?.message || error?.data?.error || 'Die Anfrage konnte nicht ausgeführt werden.'
|
||||
// Colons cannot occur in normalized internal room keys, so both room types stay unambiguous.
|
||||
const collaborationKey = id => `collaboration:${id}`
|
||||
const nativeEndpoint = (key, suffix = '') => `/api/communication/chat/rooms/${encodeURIComponent(key)}${suffix}`
|
||||
|
||||
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 decoratedRooms = computed(() => [
|
||||
...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" })),
|
||||
...directRooms.value.map((room) => ({ ...room, group: "Direktnachrichten", icon: "i-heroicons-user-circle" }))
|
||||
].map((room) => ({ ...room, unread: unreadRooms.value[room.key]?.count || 0 })))
|
||||
const groupedRooms = computed(() => ["Räume", "Projekte", "Direktnachrichten"]
|
||||
.map((label) => ({ label, rooms: decoratedRooms.value.filter((room) => room.group === label) }))
|
||||
.filter((group) => group.rooms.length))
|
||||
const activeRoom = computed(() => decoratedRooms.value.find((room) => room.key === activeRoomKey.value) || null)
|
||||
const roomEndpoint = computed(() => `/api/communication/chat/rooms/${encodeURIComponent(activeRoomKey.value)}`)
|
||||
...[
|
||||
...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' })),
|
||||
...directRooms.value.map(room => ({ ...room, group: 'Direktnachrichten', icon: 'i-heroicons-user-circle' }))
|
||||
].map(room => ({ ...room, source: 'internal', unread: unreadRooms.value[room.key]?.count || 0 })),
|
||||
...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: 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')
|
||||
|
||||
const mergeMessages = (incoming) => {
|
||||
const byId = new Map(messages.value.map((message) => [message.id, message]))
|
||||
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; syncAfterId = 0; roomLoading.value = false; sending.value = false }
|
||||
function mergeMessages(incoming) {
|
||||
const byId = new Map(messages.value.map(message => [message.id, message]))
|
||||
for (const message of incoming || []) byId.set(message.id, message)
|
||||
messages.value = Array.from(byId.values()).sort((a, b) => Number(a.id) - Number(b.id))
|
||||
syncAfterId = Number(messages.value.at(-1)?.id || syncAfterId)
|
||||
messages.value = [...byId.values()].sort((a, b) => Number(a.id) - Number(b.id))
|
||||
syncAfterId = Number(messages.value.at(-1)?.id || 0)
|
||||
}
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
const scrollToBottom = async request => {
|
||||
await nextTick()
|
||||
if (messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight
|
||||
if (currentSelection(request) && messagesViewport.value) messagesViewport.value.scrollTop = messagesViewport.value.scrollHeight
|
||||
}
|
||||
|
||||
const loadUnread = async () => {
|
||||
async function loadUnread(scope = scopeVersion) {
|
||||
if (!hasSession.value || !currentScope(scope)) return
|
||||
try {
|
||||
unreadRooms.value = (await $api("/api/communication/chat/unread")).rooms || {}
|
||||
} catch {
|
||||
unreadRooms.value = {}
|
||||
}
|
||||
const result = await $api('/api/communication/chat/unread')
|
||||
if (currentScope(scope)) unreadRooms.value = result.rooms || {}
|
||||
} catch { /* A count failure must not interrupt an open conversation. */ }
|
||||
}
|
||||
|
||||
const loadRoomLists = async () => {
|
||||
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 || []
|
||||
}
|
||||
|
||||
const markRead = async () => {
|
||||
if (!activeRoom.value?.exists) return
|
||||
async function loadRoomLists(scope = scopeVersion) {
|
||||
if (!hasSession.value || !currentScope(scope)) return
|
||||
const request = ++nativeListRequest
|
||||
try {
|
||||
await $api(`${roomEndpoint.value}/read`, {
|
||||
method: "POST",
|
||||
body: { messageId: Number(messages.value.at(-1)?.id || 0) }
|
||||
})
|
||||
unreadRooms.value = { ...unreadRooms.value, [activeRoomKey.value]: { count: 0, mentions: 0 } }
|
||||
} catch {
|
||||
// Der Lesestatus darf die Unterhaltung nicht blockieren.
|
||||
}
|
||||
}
|
||||
|
||||
const loadActiveRoom = async () => {
|
||||
if (!activeRoom.value?.exists) return
|
||||
roomLoading.value = true
|
||||
syncAfterId = 0
|
||||
try {
|
||||
const [messageResult, memberResult] = await Promise.all([
|
||||
$api(`${roomEndpoint.value}/messages`),
|
||||
$api(`${roomEndpoint.value}/members`)
|
||||
const [rooms, projects, directs] = await Promise.all([
|
||||
$api('/api/communication/chat/rooms'), $api('/api/communication/chat/project-rooms'), $api('/api/communication/chat/direct-rooms')
|
||||
])
|
||||
messages.value = []
|
||||
mergeMessages(messageResult.messages || [])
|
||||
members.value = memberResult.members || []
|
||||
await markRead()
|
||||
await scrollToBottom()
|
||||
} catch (error) {
|
||||
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geladen werden", color: "error" })
|
||||
} finally {
|
||||
roomLoading.value = false
|
||||
}
|
||||
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) }
|
||||
}
|
||||
|
||||
const provisionRoom = async (room) => {
|
||||
const endpoint = room.type === "project"
|
||||
? `/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) => {
|
||||
async function loadCollaboration(scope = scopeVersion) {
|
||||
if (!hasSession.value || !currentScope(scope)) return
|
||||
const request = ++collaborationRequest
|
||||
try {
|
||||
if (!room.exists) await provisionRoom(room)
|
||||
else {
|
||||
activeRoomKey.value = room.key
|
||||
await router.replace({ query: { ...route.query, room: room.key } })
|
||||
}
|
||||
messages.value = []
|
||||
members.value = []
|
||||
await loadActiveRoom()
|
||||
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) {
|
||||
toast.add({ title: error?.data?.error || "Chatraum konnte nicht geöffnet werden", color: "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
|
||||
await Promise.all([loadRoomLists(scope), loadUnread(scope), loadCollaboration(scope)])
|
||||
}
|
||||
|
||||
const createRoom = async () => {
|
||||
if (!roomForm.name.trim()) return
|
||||
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)
|
||||
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
|
||||
try {
|
||||
const [result, people] = await Promise.all([
|
||||
$api(nativeEndpoint(request.key, '/messages')), $api(nativeEndpoint(request.key, '/members'))
|
||||
])
|
||||
if (!currentSelection(request)) return
|
||||
messages.value = []; mergeMessages(result.messages); members.value = people.members || []
|
||||
await markRead(request); await scrollToBottom(request)
|
||||
} catch (error) {
|
||||
if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' })
|
||||
} 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 {
|
||||
if (room.source !== 'collaboration' && !room.exists) {
|
||||
const endpoint = room.type === 'project'
|
||||
? `/api/communication/chat/project-rooms/${room.projectId}/provision`
|
||||
: `/api/communication/chat/direct-rooms/${encodeURIComponent(room.userId)}/provision`
|
||||
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 } })
|
||||
if (currentSelection(request)) await loadActiveRoom(request)
|
||||
} catch (error) { if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||
}
|
||||
async function createRoom() {
|
||||
if (!roomForm.name.trim() || creating.value) return
|
||||
const scope = scopeVersion
|
||||
creating.value = true
|
||||
try {
|
||||
const created = await $api("/api/communication/chat/rooms", {
|
||||
method: "POST",
|
||||
body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
|
||||
const created = await $api('/api/communication/chat/rooms', {
|
||||
method: 'POST', body: { name: roomForm.name, key: roomKeyPreview.value, topic: roomForm.topic }
|
||||
})
|
||||
Object.assign(roomForm, { name: "", key: "", topic: "" })
|
||||
showRoomForm.value = false
|
||||
await loadRoomLists()
|
||||
await selectRoom(created)
|
||||
} catch (error) {
|
||||
toast.add({ title: error?.data?.error || "Chatraum konnte nicht erstellt werden", color: "error" })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
if (!currentScope(scope)) return
|
||||
Object.assign(roomForm, { name: '', key: '', topic: '' }); showRoomForm.value = false
|
||||
await loadRoomLists(scope)
|
||||
if (currentScope(scope)) await selectRoom(created)
|
||||
} catch (error) { if (currentScope(scope)) toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||
finally { if (currentScope(scope)) creating.value = false }
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
const text = draft.value.trim()
|
||||
if (!text || sending.value || !activeRoom.value?.exists) return
|
||||
sending.value = true
|
||||
draft.value = ""
|
||||
async function respondToInvitation(id, accept) {
|
||||
if (invitationBusy.value) return
|
||||
const scope = scopeVersion
|
||||
invitationBusy.value = id
|
||||
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 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 = '' }
|
||||
}
|
||||
|
||||
const pollMessages = async () => {
|
||||
if (pollBusy || document.hidden || !activeRoom.value?.exists) return
|
||||
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) {
|
||||
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) }
|
||||
} finally { if (currentSelection(request)) sending.value = false }
|
||||
}
|
||||
async function pollMessages() {
|
||||
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return
|
||||
const request = selection()
|
||||
pollBusy = true
|
||||
try {
|
||||
const result = await $api(`${roomEndpoint.value}/sync?afterId=${syncAfterId}`)
|
||||
if (result.messages?.length) {
|
||||
mergeMessages(result.messages)
|
||||
await markRead()
|
||||
await scrollToBottom()
|
||||
}
|
||||
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))
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await Promise.all([loadRoomLists(), loadUnread()])
|
||||
if (!activeRoom.value) activeRoomKey.value = "allgemein"
|
||||
await loadActiveRoom()
|
||||
pollTimer = window.setInterval(pollMessages, 2500)
|
||||
const result = await $api(nativeEndpoint(request.key, `/sync?afterId=${syncAfterId}`))
|
||||
if (!currentSelection(request)) return
|
||||
if (result.messages?.length) { mergeMessages(result.messages); await markRead(request); await scrollToBottom(request) }
|
||||
await loadUnread(request.scope)
|
||||
} catch (error) {
|
||||
toast.add({ title: error?.data?.error || "Chat konnte nicht geladen werden", color: "error" })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
if (currentSelection(request) && [401, 403, 404, 410].includes(error?.statusCode || error?.status)) {
|
||||
++roomVersion; clearMessages(); void loadRoomLists(request.scope)
|
||||
}
|
||||
} 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))
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollTimer) window.clearInterval(pollTimer)
|
||||
async function initialize() {
|
||||
const scope = ++scopeVersion
|
||||
++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 Promise.all([refreshLists(), loadRoomPreferences(scope)])
|
||||
if (!currentScope(scope)) return
|
||||
loading.value = false
|
||||
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 = visibleRooms.value.find(room => room.key === requestedRoom())
|
||||
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>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 bg-gray-50 p-4 sm:p-6">
|
||||
<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]">
|
||||
<div class="flex h-full min-h-0 min-w-0 w-full flex-1 overflow-hidden bg-white">
|
||||
<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"
|
||||
: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">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
@@ -233,30 +288,67 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</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-else class="space-y-5">
|
||||
<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>
|
||||
<button
|
||||
<div
|
||||
v-for="room in group.rooms"
|
||||
:key="room.key"
|
||||
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="mb-1 flex w-full items-center rounded-lg transition hover:bg-gray-100"
|
||||
:class="room.key === activeRoomKey ? 'bg-primary-50 text-primary-700' : 'text-gray-700'"
|
||||
@click="selectRoom(room)"
|
||||
>
|
||||
<UIcon :name="room.icon" class="size-5 shrink-0" />
|
||||
<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.topic }}</span>
|
||||
</span>
|
||||
<UBadge v-if="room.unread" color="primary" size="xs">{{ room.unread }}</UBadge>
|
||||
<button
|
||||
type="button"
|
||||
class="flex min-w-0 flex-1 items-center gap-3 px-2 py-2 text-left"
|
||||
@click="selectRoom(room)"
|
||||
>
|
||||
<UIcon :name="room.icon" class="size-5 shrink-0" />
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="flex min-h-0 flex-col">
|
||||
<CollaborationRoom v-if="!loading && isCollaboration" :key="`${session}:${activeRoom.id}`" :room-id="activeRoom.id" :api="$api" embedded @changed="loadCollaboration()" @unauthorized="loadCollaboration()">
|
||||
<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">
|
||||
<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>
|
||||
@@ -264,6 +356,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<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-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">
|
||||
<UIcon name="i-heroicons-chat-bubble-oval-left-ellipsis" class="mb-3 size-10" />
|
||||
<p class="font-medium">Noch keine Nachrichten</p>
|
||||
@@ -286,7 +379,7 @@ onBeforeUnmount(() => {
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<aside class="hidden min-h-0 overflow-y-auto border-l border-gray-200 p-4 lg:block">
|
||||
<aside v-if="!isCollaboration" 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>
|
||||
<div class="space-y-3">
|
||||
<div v-for="member in members" :key="member.userId" class="flex items-center gap-3">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
const { $api } = useNuxtApp()
|
||||
const route = useRoute()
|
||||
const state = ref({ rooms: [], invitations: [], managedRooms: [], projects: [], canManage: false, contact: '' })
|
||||
const contacts = ref([]), selected = ref(''), error = ref(''), busy = ref(false), showCreate = ref(false), showManagement = ref(false)
|
||||
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 management = ref({ members: [], invitations: [] }), inviteResult = ref(null)
|
||||
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 })
|
||||
@@ -73,6 +73,7 @@ async function savePartner(origin, active = true) {
|
||||
})
|
||||
}
|
||||
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
|
||||
onMounted(async () => { await refresh(); timer = setInterval(() => { if (!document.hidden && !busy.value) void refresh() }, 15000) })
|
||||
onBeforeUnmount(() => clearInterval(timer))
|
||||
@@ -92,7 +93,7 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
<section v-if="federation" class="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div class="flex justify-between"><h2 class="font-semibold">Freigegebene Partnerinstanzen</h2><button @click="federation = null">Schließen</button></div>
|
||||
<p class="my-2 text-sm text-gray-500">Beide Firmen geben die jeweils andere Instanz frei. Einzelne Personen erhalten anschließend eine eigene Einladung.</p>
|
||||
<p v-if="!federation.enabled" class="my-2 text-sm text-amber-700">Die Föderation muss zuerst vom Betreiber aktiviert werden.</p>
|
||||
<p v-if="!federation.enabled" class="my-2 text-sm text-amber-700">{{ federation.disabledReason || 'Die Föderation muss zuerst vom Betreiber aktiviert werden.' }}</p>
|
||||
<form v-if="federation.enabled" class="flex flex-wrap gap-2" @submit.prevent="savePartner(partnerUrl)"><label class="flex-1"><span class="sr-only">Adresse der Partnerinstanz</span><input v-model="partnerUrl" type="url" required placeholder="https://partner.example/backend" class="field" /></label><UButton type="submit" :loading="busy">Partner freigeben</UButton></form>
|
||||
<div v-for="partner in federation.partners" :key="partner.origin" class="mt-3 flex items-center justify-between gap-3 text-sm"><span>{{ partner.origin }} · {{ partner.active ? 'Freigegeben' : 'Gesperrt' }}</span><UButton variant="outline" size="xs" :disabled="busy" @click="savePartner(partner.origin, !partner.active)">{{ partner.active ? 'Sperren' : 'Freigeben' }}</UButton></div>
|
||||
</section>
|
||||
@@ -120,7 +121,10 @@ onBeforeUnmount(() => clearInterval(timer))
|
||||
</button>
|
||||
</aside>
|
||||
<div class="min-w-0 space-y-4">
|
||||
<UButton v-if="selectedManaged" variant="outline" :disabled="busy" @click="run(async () => { await loadManagement(); showManagement = !showManagement })">Teilnehmer und Freigaben verwalten</UButton>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<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">
|
||||
<form class="space-y-3" @submit.prevent="invitePerson">
|
||||
<h2 class="font-semibold">Person in diesen Unterraum einladen</h2>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import dayjs from "dayjs"
|
||||
|
||||
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: string }
|
||||
type InboxDocument = { id: string; name: string; mimeType: string; size: number; receivedAt: string; suggestionStatus?: string }
|
||||
type ScannerConnection = { username: string; active: boolean; password?: string }
|
||||
type FilingFolder = { id: string; name: string; parent: string | null; standardFiletype: string | null; standardFiletypeIsOptional: boolean }
|
||||
type FilingType = { id: string; name: string }
|
||||
type FilingSuggestion = { folderId: string | null; typeId: string; confidence: "high" | "medium"; reason: string }
|
||||
type SuggestionResult = { suggestion: FilingSuggestion | null; message?: string }
|
||||
type SuggestionResult = { status: "pending" | "processing" | "ready" | "failed"; suggestion: FilingSuggestion | null; message?: string }
|
||||
type AssignmentKind = { value: string; label: string }
|
||||
type Assignment = { kind: string; id: string; name: string; available?: boolean }
|
||||
|
||||
const { $api } = useNuxtApp()
|
||||
const auth = useAuthStore()
|
||||
const config = useRuntimeConfig()
|
||||
const fileActions = useFiles()
|
||||
const toast = useToast()
|
||||
const inboxCount = useDocumentInboxCount()
|
||||
const documents = ref<InboxDocument[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
@@ -33,6 +36,12 @@ const previewDocument = ref<InboxDocument | null>(null)
|
||||
const filingDocument = ref<InboxDocument | null>(null)
|
||||
const filingLoading = ref(false)
|
||||
const filingSaving = ref(false)
|
||||
const archiveOpen = ref(false)
|
||||
const archiveTarget = ref<InboxDocument | null>(null)
|
||||
const archiveSaving = ref(false)
|
||||
const archiveError = ref("")
|
||||
let archiveVersion = 0
|
||||
const documentBusy = computed(() => filingSaving.value || archiveSaving.value)
|
||||
const filingError = ref("")
|
||||
const filingFolders = ref<FilingFolder[]>([])
|
||||
const filingTypes = ref<FilingType[]>([])
|
||||
@@ -43,9 +52,22 @@ const suggestionLoading = ref(false)
|
||||
const suggestionError = ref("")
|
||||
const suggestionMessage = ref("")
|
||||
const selectionTouched = ref(false)
|
||||
const suggestionCache = new Map<string, SuggestionResult>()
|
||||
const suggestionStatus = ref("")
|
||||
let suggestionTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let suggestionController: AbortController | undefined
|
||||
let suggestionVersion = 0
|
||||
const assignmentKinds = ref<AssignmentKind[]>([])
|
||||
const assignments = ref<Assignment[]>([])
|
||||
const assignmentChanges = ref<Record<string, string | null>>({})
|
||||
const assignmentKind = ref("project")
|
||||
const assignmentSearch = ref("")
|
||||
const assignmentOptions = ref<{ id: string; name: string }[]>([])
|
||||
const assignmentId = ref<string | null>(null)
|
||||
const assignmentLoading = ref(false)
|
||||
const assignmentError = ref("")
|
||||
let assignmentVersion = 0
|
||||
let assignmentTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let assignmentController: AbortController | undefined
|
||||
const selectedFolder = computed(() => filingFolders.value.find(folder => folder.id === filingFolder.value))
|
||||
const fixedType = computed(() => Boolean(selectedFolder.value?.standardFiletype && !selectedFolder.value.standardFiletypeIsOptional))
|
||||
const folderOptions = computed(() => {
|
||||
@@ -64,7 +86,7 @@ const folderOptions = computed(() => {
|
||||
return [{ id: "root", name: "Dateien (Hauptebene)" }, ...filingFolders.value
|
||||
.map(folder => ({ id: folder.id, name: path(folder) })).sort((a, b) => a.name.localeCompare(b.name, "de"))]
|
||||
})
|
||||
const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !filingSaving.value
|
||||
const canFile = computed(() => Boolean(filingDocument.value && !filingLoading.value && !documentBusy.value
|
||||
&& (filingFolder.value === "root" || selectedFolder.value)
|
||||
&& filingTypes.value.some(type => type.id === filingType.value)
|
||||
&& (!fixedType.value || filingType.value === selectedFolder.value?.standardFiletype)))
|
||||
@@ -125,8 +147,14 @@ const applySuggestion = () => {
|
||||
|
||||
const loadFilingSuggestion = async (refresh = false) => {
|
||||
const entry = filingDocument.value
|
||||
if (!entry || filingLoading.value || filingSaving.value || suggestionLoading.value) return
|
||||
if (!entry || disposed || filingLoading.value || suggestionLoading.value) return
|
||||
if (filingSaving.value) {
|
||||
clearTimeout(suggestionTimer)
|
||||
suggestionTimer = setTimeout(() => loadFilingSuggestion(), 2000)
|
||||
return
|
||||
}
|
||||
const version = ++suggestionVersion
|
||||
clearTimeout(suggestionTimer)
|
||||
const filingRequest = filingVersion
|
||||
suggestionController?.abort()
|
||||
suggestionController = new AbortController()
|
||||
@@ -134,18 +162,28 @@ const loadFilingSuggestion = async (refresh = false) => {
|
||||
suggestionError.value = ""
|
||||
suggestionMessage.value = ""
|
||||
try {
|
||||
const result = (!refresh && suggestionCache.get(entry.id)) || await $api<SuggestionResult>(`/api/document-inbox/${entry.id}/suggestion`, {
|
||||
method: "POST", signal: suggestionController.signal,
|
||||
const result = await $api<SuggestionResult>(`/api/document-inbox/${entry.id}/suggestion`, {
|
||||
method: refresh ? "POST" : "GET", signal: suggestionController.signal,
|
||||
})
|
||||
if (disposed || version !== suggestionVersion || filingRequest !== filingVersion) return
|
||||
suggestionStatus.value = result.status
|
||||
if (["pending", "processing"].includes(result.status)) {
|
||||
suggestion.value = null
|
||||
suggestionMessage.value = result.status === "pending" ? "KI-Vorschlag wird im Hintergrund vorbereitet …" : "Der Dokumentinhalt wird geprüft …"
|
||||
suggestionTimer = setTimeout(() => loadFilingSuggestion(), 2000)
|
||||
return
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
suggestion.value = null
|
||||
suggestionError.value = result.message || "Die KI-Vorbereitung ist fehlgeschlagen. Bitte erneut prüfen oder manuell zuordnen."
|
||||
return
|
||||
}
|
||||
const proposed = result.suggestion
|
||||
const folder = filingFolders.value.find(folder => folder.id === proposed?.folderId)
|
||||
const valid = proposed && filingTypes.value.some(type => type.id === proposed.typeId)
|
||||
&& (proposed.folderId === null || (folder && (!folder.standardFiletype || folder.standardFiletypeIsOptional || folder.standardFiletype === proposed.typeId)))
|
||||
suggestion.value = valid ? proposed : null
|
||||
suggestionMessage.value = valid ? "" : result.message || "Keine passende Zuordnung gefunden. Du kannst Ordner und Typ selbst auswählen."
|
||||
if (suggestionCache.size >= 50) suggestionCache.delete(suggestionCache.keys().next().value!)
|
||||
suggestionCache.set(entry.id, { suggestion: suggestion.value, message: suggestionMessage.value })
|
||||
// A delayed AI response must not replace choices the user has already made.
|
||||
if (!selectionTouched.value) applySuggestion()
|
||||
} catch (cause: any) {
|
||||
@@ -156,15 +194,64 @@ const loadFilingSuggestion = async (refresh = false) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadAssignmentOptions = async () => {
|
||||
if (!previewOpen.value || !assignmentKinds.value.some(kind => kind.value === assignmentKind.value)) return
|
||||
const version = ++assignmentVersion
|
||||
assignmentController?.abort()
|
||||
assignmentController = new AbortController()
|
||||
assignmentLoading.value = true
|
||||
assignmentError.value = ""
|
||||
assignmentId.value = null
|
||||
assignmentOptions.value = []
|
||||
try {
|
||||
const options = await $api<{ id: string; name: string }[]>("/api/document-inbox/assignment-options", {
|
||||
query: { kind: assignmentKind.value, search: assignmentSearch.value.trim() }, signal: assignmentController.signal,
|
||||
})
|
||||
if (!disposed && version === assignmentVersion) assignmentOptions.value = options
|
||||
} catch {
|
||||
if (!disposed && version === assignmentVersion) assignmentError.value = "Zuordnungen konnten nicht geladen werden. Bitte erneut versuchen."
|
||||
} finally {
|
||||
if (version === assignmentVersion) assignmentLoading.value = false
|
||||
}
|
||||
}
|
||||
const addAssignment = () => {
|
||||
if (filingSaving.value || assignmentLoading.value) return
|
||||
const entry = assignmentOptions.value.find(option => option.id === assignmentId.value)
|
||||
if (!entry) return
|
||||
assignments.value = [...assignments.value.filter(item => item.kind !== assignmentKind.value), { kind: assignmentKind.value, ...entry }]
|
||||
assignmentChanges.value[assignmentKind.value] = entry.id
|
||||
assignmentId.value = null
|
||||
}
|
||||
const removeAssignment = (kind: string) => {
|
||||
if (filingSaving.value) return
|
||||
assignments.value = assignments.value.filter(item => item.kind !== kind)
|
||||
assignmentChanges.value[kind] = null
|
||||
}
|
||||
|
||||
const openPreview = (entry: InboxDocument) => openFiling(entry)
|
||||
|
||||
const openFiling = async (entry: InboxDocument) => {
|
||||
if (disposed || documentBusy.value) return
|
||||
suggestionVersion++
|
||||
clearTimeout(suggestionTimer)
|
||||
suggestionController?.abort()
|
||||
suggestion.value = null
|
||||
suggestionLoading.value = false
|
||||
suggestionError.value = ""
|
||||
suggestionMessage.value = ""
|
||||
suggestionStatus.value = ""
|
||||
assignmentVersion++
|
||||
assignmentController?.abort()
|
||||
clearTimeout(assignmentTimer)
|
||||
assignments.value = []
|
||||
assignmentChanges.value = {}
|
||||
assignmentKinds.value = []
|
||||
assignmentOptions.value = []
|
||||
assignmentId.value = null
|
||||
assignmentLoading.value = false
|
||||
assignmentKind.value = "project"
|
||||
assignmentSearch.value = ""
|
||||
assignmentError.value = ""
|
||||
selectionTouched.value = false
|
||||
previewOpen.value = true
|
||||
previewDocument.value = entry
|
||||
@@ -178,10 +265,16 @@ const openFiling = async (entry: InboxDocument) => {
|
||||
filingSaving.value = false
|
||||
const version = ++filingVersion
|
||||
try {
|
||||
const result = await $api<{ folders: FilingFolder[]; types: FilingType[] }>("/api/document-inbox/filing-options")
|
||||
const [result, current] = await Promise.all([
|
||||
$api<{ folders: FilingFolder[]; types: FilingType[]; assignmentKinds: AssignmentKind[] }>("/api/document-inbox/filing-options"),
|
||||
$api<Assignment[]>(`/api/document-inbox/${entry.id}/assignments`),
|
||||
])
|
||||
if (disposed || version !== filingVersion) return
|
||||
filingFolders.value = result.folders
|
||||
filingTypes.value = result.types
|
||||
assignmentKinds.value = result.assignmentKinds || []
|
||||
assignments.value = current
|
||||
void loadAssignmentOptions()
|
||||
} catch (cause: any) {
|
||||
if (version === filingVersion) filingError.value = cause?.data?.error || cause?.data?.message || "Ordner und Dokumenttypen konnten nicht geladen werden."
|
||||
} finally {
|
||||
@@ -192,6 +285,44 @@ const openFiling = async (entry: InboxDocument) => {
|
||||
}
|
||||
}
|
||||
|
||||
const removeInboxEntry = (id: string) => {
|
||||
// Discard older list responses that still contain the completed document.
|
||||
requestVersion++
|
||||
clearTimeout(pollTimer)
|
||||
if (documents.value.some(document => document.id === id)) total.value = Math.max(0, total.value - 1)
|
||||
documents.value = documents.value.filter(document => document.id !== id)
|
||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
void inboxCount.refresh()
|
||||
if (previewDocument.value?.id === id) previewOpen.value = false
|
||||
}
|
||||
|
||||
const openArchive = (entry: InboxDocument) => {
|
||||
if (disposed || documentBusy.value || !auth.activeTenant) return
|
||||
archiveTarget.value = entry
|
||||
archiveError.value = ""
|
||||
archiveOpen.value = true
|
||||
}
|
||||
|
||||
const archiveDocument = async () => {
|
||||
const entry = archiveTarget.value
|
||||
if (!entry || !archiveOpen.value || disposed || documentBusy.value || !auth.activeTenant) return
|
||||
const version = ++archiveVersion
|
||||
archiveSaving.value = true
|
||||
archiveError.value = ""
|
||||
try {
|
||||
await $api(`/api/document-inbox/${entry.id}/archive`, { method: "POST" })
|
||||
if (disposed || version !== archiveVersion) return
|
||||
removeInboxEntry(entry.id)
|
||||
archiveOpen.value = false
|
||||
toast.add({ title: "Dokument archiviert", description: `${entry.name} wurde aus der Ablage entfernt.`, color: "success" })
|
||||
await load(true)
|
||||
} catch (cause: any) {
|
||||
if (!disposed && version === archiveVersion) archiveError.value = cause?.data?.error || cause?.data?.message || "Das Dokument konnte nicht archiviert werden."
|
||||
} finally {
|
||||
if (version === archiveVersion) archiveSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fileDocument = async () => {
|
||||
if (!canFile.value) return
|
||||
const entry = filingDocument.value!
|
||||
@@ -200,16 +331,9 @@ const fileDocument = async () => {
|
||||
filingSaving.value = true
|
||||
filingError.value = ""
|
||||
try {
|
||||
await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value } })
|
||||
await $api(`/api/document-inbox/${entry.id}/file`, { method: "POST", body: { folder, type: filingType.value, assignments: { ...assignmentChanges.value } } })
|
||||
if (disposed || version !== filingVersion) return
|
||||
// Cancel pending polling responses before removing the successfully filed entry.
|
||||
requestVersion++
|
||||
clearTimeout(pollTimer)
|
||||
documents.value = documents.value.filter(document => document.id !== entry.id)
|
||||
total.value = Math.max(0, total.value - 1)
|
||||
page.value = Math.min(page.value, Math.max(1, Math.ceil(total.value / pageSize)))
|
||||
suggestionCache.delete(entry.id)
|
||||
previewOpen.value = false
|
||||
removeInboxEntry(entry.id)
|
||||
toast.add({ title: "Dokument abgelegt", description: `${entry.name} ist unter Dateien verfügbar.`, color: "success" })
|
||||
await load(true)
|
||||
} catch (cause: any) {
|
||||
@@ -304,14 +428,35 @@ watch(filingFolder, () => {
|
||||
const defaultType = selectedFolder.value?.standardFiletype
|
||||
if (defaultType) filingType.value = defaultType
|
||||
}, { flush: "sync" })
|
||||
watch([assignmentKind, assignmentSearch], ([kind, search], [previousKind]) => {
|
||||
// SelectMenu clears its search after choosing an item; keep that selection available for adding.
|
||||
if (kind === previousKind && !search && assignmentId.value) return
|
||||
assignmentVersion++
|
||||
assignmentController?.abort()
|
||||
assignmentId.value = null
|
||||
assignmentOptions.value = []
|
||||
clearTimeout(assignmentTimer)
|
||||
if (previewOpen.value) assignmentTimer = setTimeout(loadAssignmentOptions, 250)
|
||||
})
|
||||
watch(previewOpen, open => {
|
||||
if (!open) {
|
||||
suggestionVersion++
|
||||
clearTimeout(suggestionTimer)
|
||||
suggestionController?.abort()
|
||||
suggestion.value = null
|
||||
suggestionLoading.value = false
|
||||
suggestionError.value = ""
|
||||
suggestionMessage.value = ""
|
||||
suggestionStatus.value = ""
|
||||
assignmentVersion++
|
||||
assignmentController?.abort()
|
||||
clearTimeout(assignmentTimer)
|
||||
assignments.value = []
|
||||
assignmentChanges.value = {}
|
||||
assignmentKinds.value = []
|
||||
assignmentOptions.value = []
|
||||
assignmentLoading.value = false
|
||||
assignmentError.value = ""
|
||||
previewDocument.value = null
|
||||
filingVersion++
|
||||
filingDocument.value = null
|
||||
@@ -322,13 +467,24 @@ watch(previewOpen, open => {
|
||||
filingLoading.value = false
|
||||
}
|
||||
})
|
||||
watch(archiveOpen, open => {
|
||||
if (!open) { archiveTarget.value = null; archiveError.value = "" }
|
||||
})
|
||||
watch(() => auth.activeTenant, () => {
|
||||
archiveVersion++
|
||||
archiveOpen.value = false
|
||||
archiveTarget.value = null
|
||||
archiveError.value = ""
|
||||
archiveSaving.value = false
|
||||
requestVersion++
|
||||
setupVersion++
|
||||
filingVersion++
|
||||
suggestionVersion++
|
||||
suggestionController?.abort()
|
||||
suggestionCache.clear()
|
||||
clearTimeout(suggestionTimer)
|
||||
assignmentVersion++
|
||||
assignmentController?.abort()
|
||||
clearTimeout(assignmentTimer)
|
||||
previewOpen.value = false
|
||||
previewDocument.value = null
|
||||
clearTimeout(searchTimer)
|
||||
@@ -345,12 +501,16 @@ watch(() => auth.activeTenant, () => {
|
||||
onMounted(() => { origin.value = window.location.origin; load() })
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
archiveVersion++
|
||||
requestVersion++
|
||||
setupVersion++
|
||||
filingVersion++
|
||||
suggestionVersion++
|
||||
suggestionController?.abort()
|
||||
suggestionCache.clear()
|
||||
clearTimeout(suggestionTimer)
|
||||
assignmentVersion++
|
||||
assignmentController?.abort()
|
||||
clearTimeout(assignmentTimer)
|
||||
clearTimeout(searchTimer)
|
||||
clearTimeout(pollTimer)
|
||||
})
|
||||
@@ -388,10 +548,18 @@ onBeforeUnmount(() => {
|
||||
</thead>
|
||||
<tbody class="divide-y divide-default">
|
||||
<tr v-for="entry in documents" :key="entry.id" class="hover:bg-elevated/30">
|
||||
<td class="max-w-sm px-4 py-4"><button class="flex items-center gap-3 text-left font-medium text-highlighted hover:text-primary" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)"><UIcon name="i-heroicons-document-text" class="h-5 w-5 shrink-0 text-muted" /><span class="break-all">{{ entry.name }}</span></button></td>
|
||||
<td class="max-w-sm px-4 py-4">
|
||||
<button class="flex items-center gap-3 text-left font-medium text-highlighted hover:text-primary" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)"><UIcon name="i-heroicons-document-text" class="h-5 w-5 shrink-0 text-muted" /><span class="break-all">{{ entry.name }}</span></button>
|
||||
<UBadge v-if="entry.suggestionStatus" class="mt-2" size="xs" variant="subtle" :color="entry.suggestionStatus === 'failed' ? 'warning' : 'neutral'">{{ entry.suggestionStatus === 'ready' ? 'KI-Prüfung abgeschlossen' : entry.suggestionStatus === 'failed' ? 'KI-Prüfung fehlgeschlagen' : 'KI wird vorbereitet' }}</UBadge>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ dayjs(entry.receivedAt).format('DD.MM.YYYY HH:mm') }}</td>
|
||||
<td class="whitespace-nowrap px-4 py-4 text-muted">{{ formatSize(entry.size) }}</td>
|
||||
<td class="px-4 py-4"><div class="flex justify-end gap-1"><UButton icon="i-heroicons-folder-arrow-down" variant="soft" :aria-label="`${entry.name} ablegen`" @click="openFiling(entry)">Ablegen</UButton><UButton icon="i-heroicons-eye" color="neutral" variant="ghost" :aria-label="`${entry.name} als Vorschau anzeigen`" @click="openPreview(entry)">Vorschau</UButton><UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="ghost" :aria-label="`${entry.name} herunterladen`" @click="fileAction(entry, true)" /></div></td>
|
||||
<td class="px-4 py-4"><div class="flex justify-end gap-1">
|
||||
<UButton icon="i-heroicons-folder-arrow-down" variant="soft" :aria-label="`${entry.name} ablegen`" :disabled="documentBusy" @click="openFiling(entry)">Ablegen</UButton>
|
||||
<UButton icon="i-heroicons-eye" color="neutral" variant="ghost" :aria-label="`${entry.name} als Vorschau anzeigen`" :disabled="documentBusy" @click="openPreview(entry)">Vorschau</UButton>
|
||||
<UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="ghost" :aria-label="`${entry.name} herunterladen`" @click="fileAction(entry, true)" />
|
||||
<UButton icon="i-heroicons-archive-box" color="neutral" variant="ghost" :aria-label="`${entry.name} archivieren`" :disabled="documentBusy" @click="openArchive(entry)">Archivieren</UButton>
|
||||
</div></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -406,7 +574,7 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
</UDashboardPanelContent>
|
||||
|
||||
<UModal v-model:open="previewOpen" :title="previewDocument?.name || 'Dokumentvorschau'" description="Dokument prüfen und mit Ordner und Typ ablegen." :dismissible="!filingSaving" :close="!filingSaving" :ui="{ content: 'w-[calc(100vw-2rem)] max-w-7xl h-[90dvh]', body: 'min-h-0 flex-1 overflow-y-auto lg:overflow-hidden p-0 sm:p-0', title: 'break-all pr-8', footer: 'flex-wrap justify-end' }">
|
||||
<UModal v-model:open="previewOpen" :title="previewDocument?.name || 'Dokumentvorschau'" description="Dokument prüfen und mit Ordner und Typ ablegen." :dismissible="!documentBusy" :close="!documentBusy" :ui="{ content: 'w-[calc(100vw-2rem)] max-w-7xl h-[90dvh]', body: 'min-h-0 flex-1 overflow-y-auto lg:overflow-hidden p-0 sm:p-0', title: 'break-all pr-8', footer: 'flex-wrap justify-end' }">
|
||||
<template #body>
|
||||
<div class="lg:flex lg:h-full lg:min-h-0">
|
||||
<div class="h-[50dvh] min-w-0 lg:h-full lg:flex-1">
|
||||
@@ -425,9 +593,9 @@ onBeforeUnmount(() => {
|
||||
<UButton size="xs" color="neutral" variant="ghost" :disabled="filingLoading || filingSaving || suggestionLoading" @click="loadFilingSuggestion(true)">Erneut prüfen</UButton>
|
||||
</div>
|
||||
<div aria-live="polite" class="space-y-3">
|
||||
<div v-if="suggestionLoading" class="space-y-2 text-sm text-muted">
|
||||
<div v-if="suggestionLoading || (['pending', 'processing'].includes(suggestionStatus) && !suggestionError)" class="space-y-2 text-sm text-muted">
|
||||
<UProgress />
|
||||
<p>Der Dokumentinhalt wird geprüft …</p>
|
||||
<p>{{ suggestionMessage || 'Vorbereiteten KI-Vorschlag laden …' }}</p>
|
||||
</div>
|
||||
<UAlert v-if="suggestionError" :title="suggestionError" color="warning" variant="subtle" />
|
||||
<div v-else-if="suggestion" class="space-y-2 rounded-lg bg-elevated/50 p-3 text-sm">
|
||||
@@ -439,7 +607,7 @@ onBeforeUnmount(() => {
|
||||
<p class="text-muted">{{ suggestion.reason }}</p>
|
||||
<UButton v-if="!suggestionSelected" size="sm" variant="soft" :disabled="filingSaving" @click="applySuggestion">KI-Vorschlag einsetzen</UButton>
|
||||
</div>
|
||||
<p v-else-if="suggestionMessage" class="text-sm text-muted">{{ suggestionMessage }}</p>
|
||||
<p v-else-if="suggestionMessage && !['pending', 'processing'].includes(suggestionStatus)" class="text-sm text-muted">{{ suggestionMessage }}</p>
|
||||
</div>
|
||||
<UAlert v-if="filingError" :title="filingError" color="error" variant="subtle" />
|
||||
<USkeleton v-if="filingLoading" class="h-32 w-full" />
|
||||
@@ -452,14 +620,36 @@ onBeforeUnmount(() => {
|
||||
</UFormField>
|
||||
<UAlert v-if="!filingTypes.length && !filingError" title="Noch keine Dokumenttypen vorhanden" description="Lege zuerst unter Stammdaten → Dateitypen einen Typ an." color="warning" variant="subtle" />
|
||||
<UAlert v-else-if="fixedType && !filingTypes.some(type => type.id === filingType)" title="Der vorgegebene Dokumenttyp ist nicht verfügbar." description="Bitte die Ordnereinstellungen korrigieren oder einen anderen Zielordner wählen." color="warning" variant="subtle" />
|
||||
<USeparator label="Zuordnungen" />
|
||||
<div v-if="assignments.length" class="space-y-2">
|
||||
<div v-for="assignment in assignments" :key="assignment.kind" class="flex items-center justify-between gap-2 rounded-lg border border-default p-2 text-sm">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs text-muted">{{ assignmentKinds.find(kind => kind.value === assignment.kind)?.label }}</p>
|
||||
<p class="break-words" :class="{ 'text-warning': assignment.available === false }">{{ assignment.name }}</p>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-x-mark" color="neutral" variant="ghost" :aria-label="`${assignment.name} entfernen`" :disabled="filingSaving" @click="removeAssignment(assignment.kind)" />
|
||||
</div>
|
||||
</div>
|
||||
<UFormField label="Bereich zuordnen">
|
||||
<USelectMenu v-model="assignmentKind" :items="assignmentKinds" value-key="value" label-key="label" :disabled="filingSaving" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Eintrag auswählen">
|
||||
<USelectMenu v-model="assignmentId" v-model:search-term="assignmentSearch" :items="assignmentOptions" value-key="id" label-key="name" placeholder="Eintrag suchen …" :search-input="{ placeholder: 'Name suchen …' }" ignore-filter :loading="assignmentLoading" :disabled="filingSaving" class="w-full" />
|
||||
</UFormField>
|
||||
<div v-if="assignmentError" class="text-sm text-error" role="status">
|
||||
{{ assignmentError }}
|
||||
<UButton size="xs" color="neutral" variant="ghost" @click="loadAssignmentOptions">Erneut versuchen</UButton>
|
||||
</div>
|
||||
<UButton icon="i-heroicons-link" variant="soft" :disabled="!assignmentId || assignmentLoading || filingSaving" @click="addAssignment">Zuordnung hinzufügen</UButton>
|
||||
</template>
|
||||
<p class="text-xs text-muted">Die KI berücksichtigt bis zu fünf PDF-Seiten bzw. die erste Bildseite. Erst mit „Übernehmen und ablegen“ wird das Dokument unter Dateien gespeichert und aus dem Eingang entfernt.</p>
|
||||
<p class="text-xs text-muted">Die KI berücksichtigt bis zu fünf PDF-Seiten bzw. die erste Bildseite. „Übernehmen und ablegen“ speichert Ordner, Typ und Zuordnungen gemeinsam und entfernt das Dokument aus dem Eingang.</p>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<template v-if="previewDocument">
|
||||
<UButton icon="i-heroicons-archive-box" color="neutral" variant="ghost" :disabled="documentBusy" @click="openArchive(previewDocument)">Archivieren</UButton>
|
||||
<UButton icon="i-heroicons-arrow-top-right-on-square" color="neutral" variant="ghost" @click="fileAction(previewDocument, false)">In neuem Tab öffnen</UButton>
|
||||
<UButton icon="i-heroicons-arrow-down-tray" color="neutral" variant="outline" @click="fileAction(previewDocument, true)">Herunterladen</UButton>
|
||||
<UButton type="submit" form="inbox-preview-filing" icon="i-heroicons-folder-arrow-down" :loading="filingSaving" :disabled="!canFile">Übernehmen und ablegen</UButton>
|
||||
@@ -467,6 +657,17 @@ onBeforeUnmount(() => {
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="archiveOpen" title="Dokument archivieren" description="Das Dokument verschwindet aus der Ablage. Die Originaldatei bleibt im Archiv erhalten." :dismissible="!archiveSaving" :close="!archiveSaving">
|
||||
<template #body>
|
||||
<p class="break-all font-medium">{{ archiveTarget?.name }}</p>
|
||||
<UAlert v-if="archiveError" class="mt-4" :title="archiveError" color="error" variant="subtle" />
|
||||
</template>
|
||||
<template #footer>
|
||||
<UButton color="neutral" variant="outline" :disabled="archiveSaving" @click="archiveOpen = false">Abbrechen</UButton>
|
||||
<UButton icon="i-heroicons-archive-box" :loading="archiveSaving" :disabled="!archiveTarget || filingSaving" @click="archiveDocument">Archivieren</UButton>
|
||||
</template>
|
||||
</UModal>
|
||||
|
||||
<UModal v-model:open="setupOpen" title="Scanner einrichten" description="Verbinde deinen Epson-Scanner mit der Dokumentenablage dieses Mandanten.">
|
||||
<template #body>
|
||||
<div class="space-y-5">
|
||||
|
||||
@@ -1717,6 +1717,16 @@ export const useDataStore = defineStore('data', () => {
|
||||
key: "customerRef",
|
||||
label: "Referenz des Kunden",
|
||||
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",
|
||||
label: "Notizen",
|
||||
|
||||
329
frontend/tests/chat-collaboration.test.cjs
Normal file
329
frontend/tests/chat-collaboration.test.cjs
Normal file
@@ -0,0 +1,329 @@
|
||||
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)
|
||||
})
|
||||
62
frontend/tests/document-inbox-count.test.cjs
Normal file
62
frontend/tests/document-inbox-count.test.cjs
Normal file
@@ -0,0 +1,62 @@
|
||||
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 ts = require('../../backend/node_modules/typescript')
|
||||
const settle = () => new Promise(setImmediate)
|
||||
|
||||
function setup(api) {
|
||||
const auth = vue.reactive({ user: { id: 'user-1' }, activeTenant: '1', profile: null, activeTenantData: {} })
|
||||
const timers = new Map()
|
||||
let nextTimer = 0
|
||||
const scope = vue.effectScope()
|
||||
const context = vm.createContext({
|
||||
exports: {}, AbortController,
|
||||
require: name => name === '@vueuse/core' ? { createSharedComposable: fn => fn } : require(name),
|
||||
ref: vue.ref, computed: vue.computed, watch: vue.watch, onScopeDispose: vue.onScopeDispose,
|
||||
useAuthStore: () => auth, useNuxtApp: () => ({ $api: api }),
|
||||
setTimeout: fn => { timers.set(++nextTimer, fn); return nextTimer }, clearTimeout: id => timers.delete(id),
|
||||
})
|
||||
const source = fs.readFileSync(`${__dirname}/../composables/useDocumentInboxCount.ts`, 'utf8')
|
||||
vm.runInContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText, context)
|
||||
const counter = scope.run(() => context.exports.useDocumentInboxCount())
|
||||
return { auth, counter, timers, close: () => scope.stop() }
|
||||
}
|
||||
|
||||
test('menu count refreshes after filing and cancels its timer when navigation is unmounted', async () => {
|
||||
let count = 3
|
||||
const ui = setup(async () => ({ total: count }))
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 3)
|
||||
assert.equal(ui.timers.size, 1)
|
||||
count = 2
|
||||
await ui.counter.refresh()
|
||||
assert.equal(ui.counter.count.value, 2)
|
||||
assert.equal(ui.timers.size, 1)
|
||||
ui.close()
|
||||
assert.equal(ui.timers.size, 0)
|
||||
})
|
||||
|
||||
test('previous tenant responses cannot replace the current badge; portal and logout clear it', async t => {
|
||||
let finish
|
||||
let requests = 0
|
||||
const ui = setup(() => ++requests === 1 ? new Promise(resolve => { finish = resolve }) : Promise.resolve({ total: 2 }))
|
||||
t.after(ui.close)
|
||||
ui.auth.activeTenant = '2'
|
||||
assert.equal(ui.counter.count.value, 0)
|
||||
await settle()
|
||||
finish({ total: 999 })
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 2)
|
||||
ui.auth.profile = { customer_for_portal: 15 }
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 0)
|
||||
assert.equal(ui.timers.size, 0)
|
||||
ui.auth.profile = null
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 2)
|
||||
ui.auth.user = null
|
||||
await settle()
|
||||
assert.equal(ui.counter.count.value, 0)
|
||||
})
|
||||
@@ -18,13 +18,15 @@ function setup(api, apiBase = 'https://fedeo.example/backend') {
|
||||
let timerId = 0
|
||||
let mount
|
||||
let unmount
|
||||
let countRefreshes = 0
|
||||
const context = vm.createContext({
|
||||
require, exports: {}, URL, AbortController,
|
||||
ref: vue.ref, computed: vue.computed, watch: vue.watch,
|
||||
useAuthStore: () => auth,
|
||||
useDocumentInboxCount: () => ({ refresh: async () => { countRefreshes++ } }),
|
||||
useRuntimeConfig: () => ({ public: { apiBase } }),
|
||||
useFiles: () => ({}), useToast: () => ({ add() {} }),
|
||||
useNuxtApp: () => ({ $api: api }),
|
||||
useNuxtApp: () => ({ $api: (url, options) => /\/assignments$/.test(url) ? Promise.resolve([]) : api(url, options) }),
|
||||
window: { location: { origin: 'https://fedeo.example' } },
|
||||
onMounted: handler => { mount = handler },
|
||||
onBeforeUnmount: handler => { unmount = handler },
|
||||
@@ -33,7 +35,7 @@ function setup(api, apiBase = 'https://fedeo.example/backend') {
|
||||
})
|
||||
const source = fs.readFileSync(`${__dirname}/../pages/organisation/dokumentenablage.vue`, 'utf8').match(/<script setup lang="ts">([\s\S]*?)<\/script>/)[1]
|
||||
vm.runInContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true } }).outputText, context)
|
||||
return { auth, timers, mount, close: () => unmount(), run: code => vm.runInContext(code, context) }
|
||||
return { auth, timers, mount, countRefreshes: () => countRefreshes, close: () => unmount(), run: code => vm.runInContext(code, context) }
|
||||
}
|
||||
|
||||
test('scanner setup preserves absolute and relative backend prefixes', async t => {
|
||||
@@ -165,6 +167,84 @@ test('filing failure preserves the document and selected values for retry', asyn
|
||||
assert.equal(ui.run('canFile.value'), true)
|
||||
})
|
||||
|
||||
test('archiving needs no classification, waits for confirmation and updates the list, preview and badge', async t => {
|
||||
const pending = deferred()
|
||||
const staleList = deferred()
|
||||
const requests = []
|
||||
let listings = 0
|
||||
const ui = setup((url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/archive')) return pending.promise
|
||||
if (url.endsWith('/filing-options')) return Promise.resolve({ folders: [], types: [] })
|
||||
if (url.endsWith('/suggestion')) return Promise.resolve({ status: 'pending', suggestion: null })
|
||||
if (++listings === 1) return staleList.promise
|
||||
return Promise.resolve({ documents: [{ id: 'remaining' }], total: 50 })
|
||||
})
|
||||
t.after(ui.close)
|
||||
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 51; page.value = 2')
|
||||
await ui.run('openPreview(documents.value[0])')
|
||||
await settle()
|
||||
assert.equal(ui.run('canFile.value'), false)
|
||||
ui.run('openArchive(documents.value[0])')
|
||||
assert.equal(requests.some(request => request.url.endsWith('/archive')), false)
|
||||
const listing = ui.run('load(true)')
|
||||
const saving = ui.run('archiveDocument()')
|
||||
await ui.run('archiveDocument()')
|
||||
await ui.run('fileDocument()')
|
||||
assert.equal(requests.filter(request => request.url.endsWith('/archive')).length, 1)
|
||||
assert.equal(requests.some(request => request.url.endsWith('/file')), false)
|
||||
assert.equal(ui.run('documents.value.length'), 1)
|
||||
pending.resolve({ id: 'scan', archived: true })
|
||||
await saving
|
||||
staleList.resolve({ documents: [{ id: 'scan' }], total: 51 })
|
||||
await listing
|
||||
assert.equal(requests.find(request => request.url.endsWith('/archive')).options.method, 'POST')
|
||||
assert.equal(ui.run('archiveOpen.value'), false)
|
||||
assert.equal(ui.run('previewOpen.value'), false)
|
||||
assert.equal(ui.run('documents.value[0].id'), 'remaining')
|
||||
assert.equal(ui.run('total.value'), 50)
|
||||
assert.equal(ui.run('page.value'), 1)
|
||||
assert.equal(ui.countRefreshes(), 1)
|
||||
})
|
||||
|
||||
test('archive cancellation and failure preserve the document and allow a retry from the list', async t => {
|
||||
let calls = 0
|
||||
const ui = setup(async () => { calls++; throw { data: { message: 'Archivieren fehlgeschlagen' } } })
|
||||
t.after(ui.close)
|
||||
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1; openArchive(documents.value[0])')
|
||||
await settle()
|
||||
ui.run('archiveOpen.value = false')
|
||||
await settle()
|
||||
assert.equal(ui.run('archiveTarget.value'), null)
|
||||
assert.equal(calls, 0)
|
||||
ui.run('openArchive(documents.value[0])')
|
||||
await ui.run('archiveDocument()')
|
||||
assert.equal(ui.run('archiveError.value'), 'Archivieren fehlgeschlagen')
|
||||
assert.equal(ui.run('archiveOpen.value'), true)
|
||||
assert.equal(ui.run('archiveSaving.value'), false)
|
||||
assert.equal(ui.run('documents.value[0].id'), 'scan')
|
||||
assert.equal(ui.run('total.value'), 1)
|
||||
assert.equal(ui.countRefreshes(), 0)
|
||||
})
|
||||
|
||||
test('archive responses from a previous tenant or unmounted page cannot update the current view', async t => {
|
||||
for (const action of ['tenant', 'unmount']) {
|
||||
const pending = deferred()
|
||||
const ui = setup(url => url.endsWith('/archive') ? pending.promise : Promise.resolve({ documents: [{ id: 'tenant-2' }], total: 1 }))
|
||||
t.after(ui.close)
|
||||
ui.run('documents.value = [{ id: "scan", name: "scan.pdf" }]; total.value = 1; openArchive(documents.value[0])')
|
||||
const saving = ui.run('archiveDocument()')
|
||||
if (action === 'tenant') ui.auth.activeTenant = '2'
|
||||
else ui.close()
|
||||
await settle()
|
||||
pending.resolve({ id: 'scan', archived: true })
|
||||
await saving
|
||||
assert.equal(ui.run('documents.value[0].id'), action === 'tenant' ? 'tenant-2' : 'scan')
|
||||
assert.equal(ui.countRefreshes(), 0)
|
||||
if (action === 'tenant') assert.equal(ui.run('archiveOpen.value'), false)
|
||||
}
|
||||
})
|
||||
|
||||
test('tenant switching discards pending folder options and filing responses', async t => {
|
||||
for (const stage of ['options', 'save']) {
|
||||
const pending = deferred()
|
||||
@@ -193,12 +273,12 @@ test('tenant switching discards pending folder options and filing responses', as
|
||||
|
||||
const aiSuggestion = { folderId: 'folder', typeId: 'invoice', confidence: 'high', reason: 'Rechnung erkannt.' }
|
||||
|
||||
test('AI preselection only files the document after confirmation and reuses its preview result', async t => {
|
||||
test('AI preselection only files the document after confirmation and reads the prepared result without triggering AI', async t => {
|
||||
const requests = []
|
||||
const ui = setup(async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
if (url.endsWith('/suggestion')) return { suggestion: aiSuggestion }
|
||||
if (url.endsWith('/suggestion')) return { status: 'ready', suggestion: aiSuggestion }
|
||||
if (url.endsWith('/file')) return { id: 'scan' }
|
||||
return { documents: [], total: 0 }
|
||||
})
|
||||
@@ -213,12 +293,14 @@ test('AI preselection only files the document after confirmation and reuses its
|
||||
await settle()
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
assert.equal(requests.filter(request => request.url.endsWith('/suggestion')).length, 1)
|
||||
assert.equal(requests.filter(request => request.url.endsWith('/suggestion')).length, 2)
|
||||
assert.ok(requests.filter(request => request.url.endsWith('/suggestion')).every(request => request.options.method === 'GET'))
|
||||
await ui.run('fileDocument()')
|
||||
const save = requests.find(request => request.url.endsWith('/file'))
|
||||
assert.equal(save.options.body.folder, 'folder')
|
||||
assert.equal(save.options.body.type, 'invoice')
|
||||
assert.equal(ui.run('previewOpen.value'), false)
|
||||
assert.equal(ui.countRefreshes(), 1)
|
||||
})
|
||||
|
||||
test('late AI results preserve manual choices, and can still be explicitly applied', async t => {
|
||||
@@ -227,7 +309,7 @@ test('late AI results preserve manual choices, and can still be explicitly appli
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
pending.resolve({ suggestion: aiSuggestion })
|
||||
pending.resolve({ status: 'ready', suggestion: aiSuggestion })
|
||||
await settle()
|
||||
assert.equal(ui.run('filingFolder.value'), 'root')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
@@ -257,10 +339,9 @@ test('closing, changing documents or switching tenant cancels and discards pendi
|
||||
// Keep the replacement document's request separate from the stale first result.
|
||||
if (action === 'document') ui.run('previewOpen.value = false')
|
||||
await settle()
|
||||
pending.resolve({ suggestion: aiSuggestion })
|
||||
pending.resolve({ status: 'ready', suggestion: aiSuggestion })
|
||||
await settle()
|
||||
assert.equal(ui.run('suggestion.value'), null)
|
||||
assert.equal(ui.run('suggestionCache.size'), 0)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -279,3 +360,113 @@ test('AI failures leave manual filing available and optional folder defaults do
|
||||
await settle()
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
})
|
||||
|
||||
test('pending preparation is polled, preserves manual choices and only explicit refresh queues work', async t => {
|
||||
const requests = []
|
||||
let state = 'pending'
|
||||
const ui = setup(async (url, options) => {
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
requests.push(options.method)
|
||||
return { status: state, suggestion: state === 'ready' ? aiSuggestion : null }
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
assert.equal(ui.run('suggestionStatus.value'), 'pending')
|
||||
assert.equal(ui.run('suggestion.value'), null)
|
||||
assert.equal(ui.timers.size, 1)
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
state = 'ready'
|
||||
await ui.run('loadFilingSuggestion()')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
assert.equal(ui.run('suggestion.value.typeId'), 'invoice')
|
||||
assert.equal(ui.timers.size, 0)
|
||||
await ui.run('loadFilingSuggestion(true)')
|
||||
assert.deepEqual(requests, ['GET', 'GET', 'POST'])
|
||||
})
|
||||
|
||||
test('preparation polling resumes when a concurrent manual filing attempt fails', async t => {
|
||||
const save = deferred()
|
||||
let state = 'processing'
|
||||
const ui = setup(async url => {
|
||||
if (url.endsWith('/filing-options')) return filingOptions
|
||||
if (url.endsWith('/suggestion')) return { status: state, suggestion: state === 'ready' ? aiSuggestion : null }
|
||||
if (url.endsWith('/file')) { await save.promise; throw { data: { error: 'Bitte erneut versuchen' } } }
|
||||
})
|
||||
t.after(ui.close)
|
||||
const tick = async () => {
|
||||
assert.equal(ui.timers.size, 1)
|
||||
const [id, callback] = [...ui.timers][0]
|
||||
ui.timers.delete(id)
|
||||
await callback()
|
||||
}
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
ui.run('filingType.value = "other"; selectionTouched.value = true')
|
||||
const filing = ui.run('fileDocument()')
|
||||
await tick()
|
||||
assert.equal(ui.timers.size, 1, 'polling remains scheduled while filing is in progress')
|
||||
save.resolve()
|
||||
await filing
|
||||
assert.equal(ui.run('previewOpen.value'), true)
|
||||
state = 'ready'
|
||||
await tick()
|
||||
assert.equal(ui.run('suggestion.value.typeId'), 'invoice')
|
||||
assert.equal(ui.run('filingType.value'), 'other')
|
||||
assert.equal(ui.timers.size, 0)
|
||||
})
|
||||
|
||||
test('entity assignments are submitted together with filing and search reset preserves a selected item', async t => {
|
||||
const requests = []
|
||||
const ui = setup(async (url, options) => {
|
||||
requests.push({ url, options })
|
||||
if (url.endsWith('/filing-options')) return { ...filingOptions, assignmentKinds: [{ value: 'project', label: 'Projekt' }, { value: 'customer', label: 'Kunde' }] }
|
||||
if (url.endsWith('/assignment-options')) return [{ id: '11', name: 'Umbau' }]
|
||||
if (url.endsWith('/suggestion')) return { status: 'ready', suggestion: aiSuggestion }
|
||||
if (url.endsWith('/file')) return { id: 'scan' }
|
||||
return { documents: [], total: 0 }
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
await settle()
|
||||
ui.run('assignmentSearch.value = "Umbau"')
|
||||
await settle()
|
||||
await ui.run('loadAssignmentOptions()')
|
||||
ui.run('assignmentId.value = "11"; assignmentSearch.value = ""')
|
||||
await settle()
|
||||
assert.equal(ui.run('assignmentId.value'), '11')
|
||||
ui.run('addAssignment()')
|
||||
assert.equal(ui.run('assignments.value[0].name'), 'Umbau')
|
||||
assert.equal(requests.some(request => request.url.endsWith('/file')), false)
|
||||
ui.run('assignmentKind.value = "customer"')
|
||||
await settle()
|
||||
ui.run('assignmentOptions.value = [{ id: "21", name: "Kunde" }]; assignmentId.value = "21"; assignmentLoading.value = false; addAssignment(); removeAssignment("project")')
|
||||
await ui.run('fileDocument()')
|
||||
const saved = requests.find(request => request.url.endsWith('/file'))
|
||||
assert.equal(saved.options.body.assignments.project, null)
|
||||
assert.equal(saved.options.body.assignments.customer, '21')
|
||||
assert.equal(saved.options.body.folder, 'folder')
|
||||
assert.equal(saved.options.body.type, 'invoice')
|
||||
assert.equal(ui.countRefreshes(), 1)
|
||||
})
|
||||
|
||||
test('old entity search responses are discarded on category and tenant changes', async t => {
|
||||
const pending = deferred()
|
||||
const ui = setup(url => {
|
||||
if (url.endsWith('/filing-options')) return Promise.resolve({ ...filingOptions, assignmentKinds: [{ value: 'project', label: 'Projekt' }, { value: 'customer', label: 'Kunde' }] })
|
||||
if (url.endsWith('/assignment-options')) return pending.promise
|
||||
if (url.endsWith('/suggestion')) return Promise.resolve({ status: 'ready', suggestion: null })
|
||||
return Promise.resolve({ documents: [], total: 0 })
|
||||
})
|
||||
t.after(ui.close)
|
||||
await ui.run('openPreview({ id: "scan", name: "scan.pdf" })')
|
||||
ui.run('assignmentKind.value = "customer"')
|
||||
await settle()
|
||||
ui.auth.activeTenant = '2'
|
||||
await settle()
|
||||
pending.resolve([{ id: 'foreign', name: 'Previous tenant project' }])
|
||||
await settle()
|
||||
assert.equal(ui.run('assignmentOptions.value.length'), 0)
|
||||
assert.equal(ui.run('assignments.value.length'), 0)
|
||||
assert.equal(ui.timers.size, 1, 'only the inbox list polling remains')
|
||||
})
|
||||
|
||||
@@ -82,14 +82,14 @@ async function sendTestPush() {
|
||||
});
|
||||
await Promise.all([refreshInstance(), refreshJobs(), refreshDevices()]);
|
||||
toast.add({
|
||||
title: "Test-Push gesendet",
|
||||
description: `${result.sent ?? 0} gesendet, ${result.failed ?? 0} fehlgeschlagen`,
|
||||
color: result.failed ? "warning" : "success",
|
||||
title: result.failed ? (result.sent ? "Test-Push teilweise fehlgeschlagen" : "Test-Push fehlgeschlagen") : "Test-Push gesendet",
|
||||
description: [`${result.sent ?? 0} gesendet, ${result.failed ?? 0} fehlgeschlagen`, result.lastErrorCode, result.lastErrorMessage].filter(Boolean).join(" · "),
|
||||
color: result.failed ? (result.sent ? "warning" : "error") : "success",
|
||||
});
|
||||
} catch (error: any) {
|
||||
toast.add({
|
||||
title: "Test-Push fehlgeschlagen",
|
||||
description: error?.data?.message || error?.message || "Die Testnachricht konnte nicht gesendet werden.",
|
||||
description: error?.data?.message || error?.data?.error || error?.message || "Die Testnachricht konnte nicht gesendet werden.",
|
||||
color: "error",
|
||||
});
|
||||
} finally {
|
||||
@@ -246,6 +246,7 @@ async function sendTestPush() {
|
||||
<th>Angenommen</th>
|
||||
<th>Gesendet</th>
|
||||
<th>Fehler</th>
|
||||
<th>Letzter Fehler</th>
|
||||
<th>Erstellt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -256,6 +257,10 @@ async function sendTestPush() {
|
||||
<td>{{ job.acceptedCount }}</td>
|
||||
<td>{{ job.sentCount }}</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>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -17,6 +17,7 @@ from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -34,6 +35,8 @@ LEGACY_MATRIX_SERVICES = {"matrix-db", "matrix-synapse", "matrix-redis", "matrix
|
||||
"matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"}
|
||||
CORE_SERVICES = {"backend", "frontend", "db", "traefik", "node-exporter"}
|
||||
IMAGE_ID = re.compile(r"sha256:[a-f0-9]{64}\Z")
|
||||
UPDATE_JOB = re.compile(r"[0-9]{8}-[0-9]{6}-[a-f0-9]{8}\Z")
|
||||
BACKUPS_TO_KEEP = 2
|
||||
|
||||
|
||||
def compose_strings(value, *, escape=False):
|
||||
@@ -882,6 +885,137 @@ class Updater:
|
||||
raise
|
||||
time.sleep(2)
|
||||
|
||||
def _backup_manifest(self, job, mounts, *, verify=False):
|
||||
"""Only updater-owned regular backup files may be retained or removed."""
|
||||
directory = job / "backup"
|
||||
device = self.home.stat().st_dev
|
||||
for path in (job, directory):
|
||||
info = path.lstat()
|
||||
if not stat.S_ISDIR(info.st_mode) or info.st_dev != device:
|
||||
raise UpdateError("Backup-Verzeichnis ist kein reguläres lokales Verzeichnis.")
|
||||
if any(path == directory or directory in path.parents or path in directory.parents for path in mounts):
|
||||
raise UpdateError("Backup ist eingehängt oder wird von einem Container verwendet.")
|
||||
files = set()
|
||||
size = 0
|
||||
for parent, directories, names in os.walk(directory, followlinks=False):
|
||||
for name in directories + names:
|
||||
path = Path(parent) / name
|
||||
info = path.lstat()
|
||||
if info.st_dev != device or not (stat.S_ISDIR(info.st_mode) or stat.S_ISREG(info.st_mode)):
|
||||
raise UpdateError("Backup enthält Links, besondere Dateien oder fremde Dateisysteme.")
|
||||
if stat.S_ISREG(info.st_mode):
|
||||
files.add(str(path.relative_to(directory)))
|
||||
size += info.st_blocks * 512
|
||||
marker = job / "backup-pruned.json"
|
||||
if marker.is_symlink() or (marker.exists() and not stat.S_ISREG(marker.lstat().st_mode)):
|
||||
raise UpdateError("Backup-Löschprotokoll muss eine reguläre Datei sein.")
|
||||
pending = read_json(marker, {})
|
||||
if pending.get("status") == "deleting" and not verify:
|
||||
# Resume an interrupted deletion using the manifest saved before removing any file.
|
||||
manifest = pending["manifest"]
|
||||
else:
|
||||
manifest = read_json(directory / "complete.json")
|
||||
checksums = manifest["sha256"]
|
||||
required = {"database.dump", "globals.sql", "files.tar", "files.list", "containers.json",
|
||||
"config/.env", "config/docker-compose.yml"}
|
||||
if manifest.get("storageProvider") == "seaweedfs":
|
||||
required |= {"storage-image.tar", "config/.updates/storage-provider.json"}
|
||||
if (not isinstance(checksums, dict) or not required <= checksums.keys()
|
||||
or manifest.get("database") != "database.dump" or manifest.get("files") != "files.tar"):
|
||||
raise UpdateError("Unvollständiges Backup-Prüfmanifest.")
|
||||
for name, digest in checksums.items():
|
||||
path = Path(name)
|
||||
if (path.is_absolute() or ".." in path.parts or str(path) != name or name == "complete.json"
|
||||
or not re.fullmatch(r"[a-f0-9]{64}", str(digest))):
|
||||
raise UpdateError("Ungültiger Pfad oder Prüfsumme im Backup-Manifest.")
|
||||
if not files <= (checksums.keys() | {"complete.json"}):
|
||||
raise UpdateError("Backup enthält unbekannte Dateien.")
|
||||
if verify:
|
||||
if files != checksums.keys() | {"complete.json"}:
|
||||
raise UpdateError("Eine Datei der aufzubewahrenden Sicherung fehlt.")
|
||||
for name, digest in checksums.items():
|
||||
if file_sha(directory / name) != digest:
|
||||
raise UpdateError("Prüfsumme einer aufzubewahrenden Sicherung stimmt nicht.")
|
||||
return manifest, size
|
||||
|
||||
def _prune_backups(self, *, dry_run=False):
|
||||
"""Caller holds the update lock. Never select migration or failed-job backups."""
|
||||
jobs = []
|
||||
for job in self.home.iterdir():
|
||||
if not UPDATE_JOB.fullmatch(job.name) or job.is_symlink() or not job.is_dir():
|
||||
continue
|
||||
status_path = job / "status.json"
|
||||
if status_path.is_symlink() or not status_path.is_file():
|
||||
continue
|
||||
state = read_json(job / "status.json", {})
|
||||
if (state.get("command") == "apply" and state.get("status") == "succeeded"
|
||||
and state.get("phase") == "complete" and state.get("recoveryRequired") is False
|
||||
and state.get("maintenance") is False and state.get("job") == str(job)
|
||||
and state.get("backup") == str(job / "backup")
|
||||
and ((job / "backup").exists() or (job / "backup").is_symlink())):
|
||||
started = datetime.datetime.fromisoformat(state["startedAt"])
|
||||
if started.tzinfo is None:
|
||||
raise UpdateError("Backup-Auftrag hat keine eindeutige Zeitangabe.")
|
||||
jobs.append((started, job, state))
|
||||
jobs.sort(key=lambda item: (item[0], item[1].name), reverse=True)
|
||||
report = {"status": "preview" if dry_run else "complete", "keep": BACKUPS_TO_KEEP,
|
||||
"checkedAt": now(), "retained": [], "removed": [], "planned": [], "skipped": []}
|
||||
if not jobs:
|
||||
return report
|
||||
installed = read_json(self.home / "installed.json", {})
|
||||
latest = jobs[0][2]
|
||||
if (installed.get("manifest", {}).get("version") != latest.get("version")
|
||||
or installed.get("manifest", {}).get("commit") != latest.get("revision")
|
||||
or installed.get("composeSha256") != file_sha(self.root / "docker-compose.yml")):
|
||||
raise UpdateError("Neueste erfolgreiche Sicherung gehört nicht zum installierten Stand; nichts gelöscht.")
|
||||
# Include same-device bind mounts, which os.path.ismount alone cannot detect.
|
||||
mounts = []
|
||||
for line in Path("/proc/self/mountinfo").read_text().splitlines():
|
||||
value = re.sub(r"\\([0-7]{3})", lambda m: chr(int(m[1], 8)), line.split()[4])
|
||||
path = Path(value)
|
||||
if path != Path("/") and (path == self.home or self.home in path.parents):
|
||||
mounts.append(path)
|
||||
for cid in self.runner.run(["docker", "ps", "--all", "--quiet"]).split():
|
||||
for mount in self.inspect(cid).get("Mounts", []):
|
||||
path = Path(mount["Source"]).resolve()
|
||||
# A read-only host-root metrics mount does not consume backup data.
|
||||
if path == Path("/") and not mount.get("RW", True):
|
||||
continue
|
||||
mounts.append(path)
|
||||
# Check both retained generations before deleting any older generation.
|
||||
for _, job, _ in jobs[:BACKUPS_TO_KEEP]:
|
||||
self._backup_manifest(job, mounts, verify=True)
|
||||
report["retained"].append(str(job / "backup"))
|
||||
for _, job, _ in jobs[BACKUPS_TO_KEEP:]:
|
||||
try:
|
||||
manifest, size = self._backup_manifest(job, mounts)
|
||||
item = {"backup": str(job / "backup"), "bytes": size}
|
||||
if dry_run:
|
||||
report["planned"].append(item)
|
||||
continue
|
||||
marker = {"status": "deleting", "startedAt": now(), "manifest": manifest,
|
||||
"retained": report["retained"], **item}
|
||||
write_json(job / "backup-pruned.json", marker)
|
||||
shutil.rmtree(job / "backup")
|
||||
write_json(job / "backup-pruned.json", {**marker, "status": "removed", "completedAt": now()})
|
||||
report["removed"].append(item)
|
||||
except (OSError, ValueError, KeyError, TypeError, UpdateError) as error:
|
||||
report["skipped"].append({"job": str(job), "reason": str(error) if isinstance(error, UpdateError) else type(error).__name__})
|
||||
if report["skipped"]:
|
||||
report["status"] = "warning"
|
||||
if not dry_run:
|
||||
write_json(self.home / "backup-retention.json", report)
|
||||
return report
|
||||
|
||||
def prune_backups(self, *, dry_run=False):
|
||||
with self.lock():
|
||||
state = read_json(self.home / "status.json", {})
|
||||
if (state.get("status") in ("queued", "running") or state.get("recoveryRequired")
|
||||
or state.get("maintenance") or state.get("phase") == "storage_migration"):
|
||||
raise UpdateError("Aktiven oder unterbrochenen Update-/Migrationsauftrag zuerst abschließen.")
|
||||
self.runner = self.runner_factory(self.home / "backup-retention.log")
|
||||
return self._prune_backups(dry_run=dry_run)
|
||||
|
||||
def apply(self, accept_compose):
|
||||
if not self.managed and not accept_compose:
|
||||
raise UpdateError("Compose-Datei ist noch nicht verwaltet oder wurde bearbeitet. compose.diff prüfen, Anpassungen in die Override-Datei übertragen und --accept-compose-replacement verwenden.")
|
||||
@@ -973,7 +1107,6 @@ class Updater:
|
||||
self.save("checked", status="checked")
|
||||
else:
|
||||
self.apply(accept_compose)
|
||||
return {**self.state, "plan": plan}
|
||||
except BaseException as error:
|
||||
if self.state.get("maintenance") and not self.state.get("mutationStarted"):
|
||||
try:
|
||||
@@ -989,6 +1122,25 @@ class Updater:
|
||||
self.save(status="interrupted" if isinstance(error, (KeyboardInterrupt, SystemExit)) else "failed",
|
||||
error=str(error) if isinstance(error, UpdateError) else type(error).__name__)
|
||||
raise
|
||||
# Retention is post-success maintenance, outside the update/recovery exception path.
|
||||
# An I/O error or shutdown here must never stop the newly verified application.
|
||||
if command == "apply":
|
||||
try:
|
||||
retention = self._prune_backups()
|
||||
self.save(backupRetention=retention)
|
||||
with self.runner.log.open("a") as log:
|
||||
log.write(f"{now()} Backup-Aufbewahrung: {len(retention['retained'])} behalten, "
|
||||
f"{len(retention['removed'])} entfernt, {len(retention['skipped'])} übersprungen.\n")
|
||||
except Exception as error:
|
||||
with contextlib.suppress(Exception):
|
||||
warning = {"status": "warning", "checkedAt": now(), "keep": BACKUPS_TO_KEEP,
|
||||
"error": str(error) if isinstance(error, UpdateError) else type(error).__name__}
|
||||
write_json(self.home / "backup-retention.json", warning)
|
||||
self.save(backupRetention=warning)
|
||||
with self.runner.log.open("a") as log:
|
||||
log.write(f"{now()} Backup-Bereinigung nicht abgeschlossen; Update erfolgreich. "
|
||||
"Auf dem Host prune-backups --dry-run prüfen.\n")
|
||||
return {**self.state, "plan": plan}
|
||||
|
||||
def resolve(self, version):
|
||||
if not version or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9.+-]{0,100}", version):
|
||||
@@ -1073,13 +1225,16 @@ def main(argv=None):
|
||||
print("Python ab Version 3.10 wird benötigt.", file=sys.stderr)
|
||||
return 1
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("command", choices=("check", "apply", "status", "resolve", "adopt-storage"))
|
||||
parser.add_argument("command", choices=("check", "apply", "status", "resolve", "adopt-storage", "prune-backups"))
|
||||
parser.add_argument("--directory", default=os.environ.get("FEDEO_DIR", "/opt/fedeo"))
|
||||
parser.add_argument("--version", help="Zielversion, z. B. 3.1.0; check wählt sonst den neuesten stabilen Release")
|
||||
parser.add_argument("--accept-compose-replacement", action="store_true", help="Zuvor geprüften Austausch einer nicht verwalteten Compose-Datei akzeptieren")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Nur für prune-backups: Sicherungen prüfen und Löschvorschau ausgeben")
|
||||
args = parser.parse_args(argv)
|
||||
if args.command == "apply" and not args.version:
|
||||
parser.error("apply benötigt eine explizite --version; zuerst check ausführen")
|
||||
if args.dry_run and args.command != "prune-backups":
|
||||
parser.error("--dry-run ist nur für prune-backups verfügbar")
|
||||
os.umask(0o077)
|
||||
signal.signal(signal.SIGTERM, lambda *_: sys.exit(143))
|
||||
try:
|
||||
@@ -1090,6 +1245,8 @@ def main(argv=None):
|
||||
result = updater.resolve(args.version)
|
||||
elif args.command == "adopt-storage":
|
||||
result = updater.adopt_storage()
|
||||
elif args.command == "prune-backups":
|
||||
result = updater.prune_backups(dry_run=args.dry_run)
|
||||
else:
|
||||
result = updater.execute(args.command, args.version, args.accept_compose_replacement)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Real migration -> adoption -> two updates, including a backup restore, on synthetic data.
|
||||
"""Real migration -> adoption -> three updates, retention and restore on synthetic data.
|
||||
|
||||
Requires local storage test images and registry:2; never contacts a FEDEO instance.
|
||||
Release HTTP/health responses are fixtures; Compose, registry, SDK, PostgreSQL,
|
||||
@@ -101,7 +101,8 @@ def run():
|
||||
docker('push', image['repository'] + ':fixture')
|
||||
# Copy health, networking and SDK settings from the original fixture, but start the new image's migration script.
|
||||
updater.releases = Releases(config, images)
|
||||
for version in ('3.4.0', '3.5.0'):
|
||||
backups = []
|
||||
for version in ('3.4.0', '3.5.0', '3.6.0'):
|
||||
http.version = version
|
||||
checked = updater.execute('check', version)
|
||||
assert checked['plan']['storageProvider'] == 'seaweedfs'
|
||||
@@ -112,6 +113,15 @@ def run():
|
||||
old_matrix = migration.inspect(retained)
|
||||
assert not old_matrix['State']['Running'] and old_matrix['HostConfig']['RestartPolicy']['Name'] == 'no'
|
||||
backup = Path(result['backup'])
|
||||
backups.append(backup)
|
||||
retention = result['backupRetention']
|
||||
assert retention['status'] == 'complete', retention
|
||||
assert len(retention['retained']) == min(len(backups), 2)
|
||||
if len(backups) == 3:
|
||||
assert not backups[0].exists()
|
||||
assert u.read_json(backups[0].parent / 'backup-pruned.json')['status'] == 'removed'
|
||||
assert backups[1].is_dir() and backups[2].is_dir()
|
||||
assert (root / '.updates/storage-migration/backup/complete.json').is_file()
|
||||
with tarfile.open(backup / 'files.tar') as archive:
|
||||
assert archive.extractfile('matrix/postgres/retained.txt').read() == b'retired Matrix'
|
||||
assert 'storage/config/s3.json' in archive.getnames()
|
||||
@@ -136,7 +146,8 @@ def run():
|
||||
migration.helper('restore-verify', endpoint='http://' + restore_name + ':9000')
|
||||
finally:
|
||||
docker('rm', '-f', restore_name)
|
||||
print(json.dumps({'result': 'passed', 'restoredUpdaterBackup': str(backup), 'evidence': str(root)}), flush=True)
|
||||
print(json.dumps({'result': 'passed', 'retention': 'two_generations_verified',
|
||||
'restoredUpdaterBackup': str(backup), 'evidence': str(root)}), flush=True)
|
||||
except BaseException:
|
||||
print('Failure evidence: ' + str(root), flush=True)
|
||||
for path in sorted((root / '.updates').glob('*/commands.log')):
|
||||
|
||||
190
scripts/update/tests/test_backup_retention.py
Normal file
190
scripts/update/tests/test_backup_retention.py
Normal file
@@ -0,0 +1,190 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from test_updater import u
|
||||
|
||||
|
||||
class RetentionRunner:
|
||||
def __init__(self, log, owner):
|
||||
self.log, self.owner = log, owner
|
||||
|
||||
def run(self, command):
|
||||
if command[:2] == ['docker', 'ps']:
|
||||
return 'container' if self.owner.mounts else ''
|
||||
if command[:2] == ['docker', 'inspect']:
|
||||
return json.dumps([{'Mounts': self.owner.mounts}])
|
||||
raise AssertionError(command)
|
||||
|
||||
|
||||
class RetentionTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
(self.root / 'docker-compose.yml').write_text('unchanged compose')
|
||||
self.mounts = []
|
||||
self.updater = u.Updater(self.root, runner_factory=lambda log: RetentionRunner(log, self))
|
||||
self.jobs = [self.backup(i) for i in range(1, 5)]
|
||||
latest = u.read_json(self.jobs[-1] / 'status.json')
|
||||
u.write_json(self.updater.home / 'installed.json', {
|
||||
'manifest': {'version': latest['version'], 'commit': latest['revision']},
|
||||
'composeSha256': u.file_sha(self.root / 'docker-compose.yml')})
|
||||
u.write_json(self.updater.home / 'status.json', latest)
|
||||
|
||||
def backup(self, index, *, name=None, status='succeeded', command='apply'):
|
||||
job = self.updater.home / (name or f'202601{index:02d}-120000-{index:08x}')
|
||||
directory = job / 'backup'
|
||||
directory.mkdir(parents=True)
|
||||
files = ['database.dump', 'globals.sql', 'files.tar', 'files.list', 'containers.json',
|
||||
'config/.env', 'config/docker-compose.yml']
|
||||
for name in files:
|
||||
path = directory / name
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
path.write_text(f'{index}: {name}')
|
||||
u.write_json(directory / 'complete.json', {
|
||||
'database': 'database.dump', 'files': 'files.tar', 'storageProvider': 'minio',
|
||||
'sha256': {name: u.file_sha(directory / name) for name in files}})
|
||||
u.write_json(job / 'status.json', {
|
||||
'command': command, 'status': status, 'phase': 'complete', 'job': str(job),
|
||||
'backup': str(directory), 'maintenance': False, 'recoveryRequired': False,
|
||||
'version': f'3.{index}.0', 'revision': str(index) * 40,
|
||||
'startedAt': f'2026-01-{index:02d}T12:00:00+00:00'})
|
||||
(job / 'commands.log').write_text('audit log stays')
|
||||
return job
|
||||
|
||||
def test_keep_two_and_preserve_failed_migration_and_audit_records(self):
|
||||
special = [self.backup(5, status='failed'), self.backup(6, name='storage-migration'),
|
||||
self.backup(7, name='foreign'), self.backup(8, command='check')]
|
||||
report = self.updater.prune_backups()
|
||||
self.assertEqual(report['status'], 'complete')
|
||||
self.assertEqual(len(report['removed']), 2)
|
||||
self.assertEqual(len(report['retained']), 2)
|
||||
for job in self.jobs[:2]:
|
||||
self.assertFalse((job / 'backup').exists())
|
||||
self.assertEqual(u.read_json(job / 'backup-pruned.json')['status'], 'removed')
|
||||
self.assertTrue((job / 'commands.log').is_file())
|
||||
for job in self.jobs[2:] + special:
|
||||
self.assertTrue((job / 'backup/complete.json').is_file())
|
||||
self.assertEqual(self.updater.prune_backups()['removed'], [])
|
||||
|
||||
def test_dry_run_checks_but_does_not_delete_or_write_markers(self):
|
||||
result = self.updater.prune_backups(dry_run=True)
|
||||
self.assertEqual(len(result['planned']), 2)
|
||||
self.assertFalse((self.updater.home / 'backup-retention.json').exists())
|
||||
for job in self.jobs:
|
||||
self.assertTrue((job / 'backup').is_dir())
|
||||
self.assertFalse((job / 'backup-pruned.json').exists())
|
||||
|
||||
def test_corrupt_either_retained_generation_prevents_all_deletions(self):
|
||||
for job in self.jobs[2:]:
|
||||
with self.subTest(job=job.name):
|
||||
path = job / 'backup/database.dump'
|
||||
original = path.read_bytes()
|
||||
path.write_bytes(b'corrupt')
|
||||
with self.assertRaisesRegex(u.UpdateError, 'Prüfsumme'):
|
||||
self.updater.prune_backups()
|
||||
self.assertTrue(all((p / 'backup').exists() for p in self.jobs))
|
||||
path.write_bytes(original)
|
||||
|
||||
def test_incomplete_retained_backup_prevents_deletion(self):
|
||||
(self.jobs[-1] / 'backup/globals.sql').unlink()
|
||||
with self.assertRaisesRegex(u.UpdateError, 'fehlt'):
|
||||
self.updater.prune_backups()
|
||||
self.assertTrue((self.jobs[0] / 'backup').exists())
|
||||
|
||||
def test_seaweedfs_requires_image_and_provider_snapshot(self):
|
||||
path = self.jobs[-1] / 'backup/complete.json'
|
||||
manifest = u.read_json(path)
|
||||
manifest['storageProvider'] = 'seaweedfs'
|
||||
u.write_json(path, manifest)
|
||||
with self.assertRaisesRegex(u.UpdateError, 'Unvollständiges'):
|
||||
self.updater.prune_backups()
|
||||
self.assertTrue((self.jobs[0] / 'backup').exists())
|
||||
|
||||
def test_unknown_files_and_links_are_preserved(self):
|
||||
(self.jobs[0] / 'backup/foreign.txt').write_text('do not delete')
|
||||
outside = self.root / 'outside'
|
||||
outside.write_text('keep')
|
||||
(self.jobs[1] / 'backup/files.tar').unlink()
|
||||
(self.jobs[1] / 'backup/files.tar').symlink_to(outside)
|
||||
result = self.updater.prune_backups()
|
||||
self.assertEqual(len(result['skipped']), 2)
|
||||
self.assertEqual(result['removed'], [])
|
||||
self.assertEqual(outside.read_text(), 'keep')
|
||||
|
||||
def test_symlinked_backup_directory_is_not_followed(self):
|
||||
backup = self.jobs[0] / 'backup'
|
||||
outside = self.root / 'outside'
|
||||
backup.rename(outside)
|
||||
backup.symlink_to(outside, target_is_directory=True)
|
||||
result = self.updater.prune_backups()
|
||||
self.assertTrue(outside.is_dir())
|
||||
self.assertTrue(backup.is_symlink())
|
||||
self.assertEqual(len(result['skipped']), 1)
|
||||
|
||||
def test_manifest_path_escape_is_rejected(self):
|
||||
path = self.jobs[-1] / 'backup/complete.json'
|
||||
manifest = u.read_json(path)
|
||||
manifest['sha256']['../outside'] = 'a' * 64
|
||||
u.write_json(path, manifest)
|
||||
with self.assertRaisesRegex(u.UpdateError, 'Ungültiger Pfad'):
|
||||
self.updater.prune_backups()
|
||||
self.assertTrue((self.jobs[0] / 'backup').exists())
|
||||
|
||||
def test_container_mount_protects_backup_but_metrics_root_mount_does_not(self):
|
||||
self.mounts = [{'Source': '/', 'RW': False},
|
||||
{'Source': str(self.jobs[0] / 'backup/files.tar'), 'RW': False}]
|
||||
result = self.updater.prune_backups()
|
||||
self.assertEqual(len(result['skipped']), 1)
|
||||
self.assertTrue((self.jobs[0] / 'backup').exists())
|
||||
self.assertFalse((self.jobs[1] / 'backup').exists())
|
||||
|
||||
def test_same_device_bind_mount_is_protected(self):
|
||||
read_text = Path.read_text
|
||||
mount = self.jobs[0] / 'backup/config'
|
||||
def read(path, *args, **kwargs):
|
||||
if path == Path('/proc/self/mountinfo'):
|
||||
return f'1 2 3:4 / {mount} rw - ext4 /dev/test rw\n'
|
||||
return read_text(path, *args, **kwargs)
|
||||
with patch.object(Path, 'read_text', read):
|
||||
result = self.updater.prune_backups()
|
||||
self.assertEqual(len(result['skipped']), 1)
|
||||
self.assertTrue((self.jobs[0] / 'backup').exists())
|
||||
|
||||
def test_partial_deletion_can_resume_without_touching_retained_backups(self):
|
||||
original = shutil.rmtree
|
||||
def fail(path, *args, **kwargs):
|
||||
if path == self.jobs[0] / 'backup':
|
||||
(path / 'complete.json').unlink()
|
||||
(path / 'database.dump').unlink()
|
||||
raise OSError('simulated disk error')
|
||||
return original(path, *args, **kwargs)
|
||||
with patch.object(u.shutil, 'rmtree', fail):
|
||||
result = self.updater.prune_backups()
|
||||
self.assertEqual(len(result['skipped']), 1)
|
||||
self.assertEqual(u.read_json(self.jobs[0] / 'backup-pruned.json')['status'], 'deleting')
|
||||
result = self.updater.prune_backups()
|
||||
self.assertEqual(len(result['removed']), 1)
|
||||
self.assertTrue(all((p / 'backup').exists() for p in self.jobs[2:]))
|
||||
|
||||
def test_recovery_running_and_lock_block_cleanup(self):
|
||||
for state in ({'recoveryRequired': True}, {'maintenance': True}, {'status': 'running'},
|
||||
{'phase': 'storage_migration'}):
|
||||
with self.subTest(state=state):
|
||||
u.write_json(self.updater.home / 'status.json', state)
|
||||
with self.assertRaises(u.UpdateError):
|
||||
self.updater.prune_backups()
|
||||
u.write_json(self.updater.home / 'status.json', {'status': 'failed', 'recoveryRequired': False})
|
||||
with self.updater.lock(), self.assertRaises(u.UpdateError):
|
||||
self.updater.prune_backups()
|
||||
self.assertEqual(len(self.updater.prune_backups()['removed']), 2)
|
||||
|
||||
def test_changed_installation_blocks_cleanup(self):
|
||||
(self.root / 'docker-compose.yml').write_text('manually restored')
|
||||
with self.assertRaisesRegex(u.UpdateError, 'installierten Stand'):
|
||||
self.updater.prune_backups()
|
||||
self.assertTrue(all((p / 'backup').exists() for p in self.jobs))
|
||||
@@ -420,6 +420,40 @@ class UpdateTests(unittest.TestCase):
|
||||
self.assertLess(next(i for i, a in enumerate(calls) if 'pull' in a), next(i for i, a in enumerate(calls) if 'stop' in a))
|
||||
self.assertLess(next(i for i, a in enumerate(calls) if 'pg_restore' in a), next(i for i, a in enumerate(calls) if 'migrate' in a))
|
||||
|
||||
def use_pre_federation_installation(self, extra_env=b''):
|
||||
keys = (b'FEDEO_PUBLIC_URL', b'FEDEO_FEDERATION_URL', b'FEDEO_FEDERATION_ENABLED')
|
||||
self.original_env = b''.join(line for line in self.original_env.splitlines(True)
|
||||
if not line.startswith(keys)) + extra_env
|
||||
self.original_compose = b''.join(line for line in self.original_compose.splitlines(True)
|
||||
if not line.lstrip().startswith(keys))
|
||||
(self.root / '.env').write_bytes(self.original_env)
|
||||
(self.root / 'docker-compose.yml').write_bytes(self.original_compose)
|
||||
|
||||
def test_update_adds_federation_compose_defaults_without_requiring_new_env_settings(self):
|
||||
self.use_pre_federation_installation()
|
||||
result = self.run_update()
|
||||
self.assertEqual(result['status'], 'succeeded')
|
||||
env = self.updater.candidate['services']['backend']['environment']
|
||||
self.assertEqual(env['FEDEO_FEDERATION_ENABLED'], 'auto')
|
||||
self.assertEqual(env['API_BASE_URL'], 'https://app.example.com/backend')
|
||||
self.assertEqual(env['FEDEO_FEDERATION_URL'], '')
|
||||
self.assertEqual(env['FEDEO_PUBLIC_URL'], '')
|
||||
self.assertEqual((self.root / '.env').read_bytes(), u.set_version(self.original_env, '3.1.0'))
|
||||
self.assertIn(b'FEDEO_FEDERATION_ENABLED', (self.root / 'docker-compose.yml').read_bytes())
|
||||
|
||||
def test_update_preserves_explicit_federation_opt_out_and_url_overrides(self):
|
||||
self.use_pre_federation_installation(
|
||||
b'\nFEDEO_FEDERATION_ENABLED=false\n'
|
||||
b'FEDEO_FEDERATION_URL=https://gateway.example/backend\n'
|
||||
b'FEDEO_PUBLIC_URL=https://portal.example\n')
|
||||
result = self.run_update()
|
||||
self.assertEqual(result['status'], 'succeeded')
|
||||
env = self.updater.candidate['services']['backend']['environment']
|
||||
self.assertEqual(env['FEDEO_FEDERATION_ENABLED'], 'false')
|
||||
self.assertEqual(env['FEDEO_FEDERATION_URL'], 'https://gateway.example/backend')
|
||||
self.assertEqual(env['FEDEO_PUBLIC_URL'], 'https://portal.example')
|
||||
self.assertEqual((self.root / '.env').read_bytes(), u.set_version(self.original_env, '3.1.0'))
|
||||
|
||||
def test_check_only_creates_a_plan_and_ignores_unpublished_releases(self):
|
||||
result = self.updater.execute('check')
|
||||
self.assertEqual(result['version'], '3.1.0')
|
||||
@@ -646,6 +680,39 @@ class UpdateTests(unittest.TestCase):
|
||||
self.assertFalse((self.root / '.updates/installed.json').exists())
|
||||
self.assertEqual(self.runner.calls[-1][-3:], ['backend', 'frontend', 'traefik'])
|
||||
|
||||
def test_backup_retention_runs_only_after_successful_public_verification(self):
|
||||
def prune():
|
||||
self.assertEqual(self.updater.status()['status'], 'succeeded')
|
||||
self.assertTrue(any(url.endswith('/api/health') for url in self.http.requests))
|
||||
return {'retained': [], 'removed': [], 'skipped': []}
|
||||
with patch.object(self.updater, '_prune_backups', side_effect=prune) as cleanup:
|
||||
self.run_update()
|
||||
cleanup.assert_called_once()
|
||||
|
||||
def test_failed_update_never_prunes_backups(self):
|
||||
self.http.bad_health = True
|
||||
with patch.object(self.updater, '_prune_backups') as cleanup:
|
||||
with self.assertRaises(u.UpdateError):
|
||||
self.run_update()
|
||||
cleanup.assert_not_called()
|
||||
|
||||
def test_retention_error_does_not_fail_update_or_stop_the_new_application(self):
|
||||
with patch.object(self.updater, '_prune_backups', side_effect=OSError('disk error')):
|
||||
result = self.run_update()
|
||||
self.assertEqual(result['status'], 'succeeded')
|
||||
self.assertEqual(result['backupRetention']['status'], 'warning')
|
||||
self.assertEqual(u.read_json(self.updater.home / 'backup-retention.json')['status'], 'warning')
|
||||
self.assertFalse(result['recoveryRequired'])
|
||||
self.assertEqual(sum('stop' in args for args in self.runner.calls), 1)
|
||||
|
||||
def test_interrupt_during_retention_preserves_completed_update(self):
|
||||
with patch.object(self.updater, '_prune_backups', side_effect=KeyboardInterrupt):
|
||||
with self.assertRaises(KeyboardInterrupt):
|
||||
self.run_update()
|
||||
self.assertEqual(self.updater.status()['status'], 'succeeded')
|
||||
self.assertFalse(self.updater.status()['recoveryRequired'])
|
||||
self.assertEqual(sum('stop' in args for args in self.runner.calls), 1)
|
||||
|
||||
def test_resolve_requires_a_working_manually_recovered_installation(self):
|
||||
self.runner.fail = lambda args: 'migrate' in args
|
||||
with self.assertRaises(u.UpdateError):
|
||||
|
||||
Reference in New Issue
Block a user