34 Commits

Author SHA1 Message Date
1b617c7910 Merge pull request 'dev' (#252) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m1s
Build and Release FEDEO / build-and-release (push) Successful in 6m34s
Reviewed-on: #252
2026-10-27 20:39:01 +00:00
9e003125c2 feat(mcp): Dateien ohne Base64 per HTTP hochladen
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m19s
- POST /api/mcp/files nimmt multipart/form-data oder Rohdaten mit dem MCP-Token an, Ziel Dokumentenablage oder Eingangsrechnung
- documents.files.upload_url liefert Adresse, Parameter und curl-Beispiele
- Upload einer Eingangsrechnung bereitet nur noch die hochgeladene Datei vor

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:50:34 +01:00
76e82eae4f feat(accounting): fehlende PDF von Ausgangsbelegen nacherzeugen
Auf der Belegansicht erscheint "PDF nacherzeugen", wenn ein
fertiggestellter Beleg kein angehängtes Dokument hat. Die PDF wird mit
derselben Logik wie beim Fertigstellen erzeugt und angehängt, ohne den
Datensatz zu speichern.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:40:25 +01:00
f814689ddd fix(accounting): Belegvorbereitung im Hintergrund ausführen und Dubletten verhindern
- „Belege vorbereiten“ startet den Lauf im Hintergrund (202) und fragt den Status ab; ein laufender Vorgang wird nicht erneut gestartet und beim Wiederöffnen der Seite weiterverfolgt
- Eingangsbeleg und Dateiverknüpfung werden in einer Transaktion angelegt; ist die Datei bereits verknüpft, wird der neue Beleg verworfen
- Fehler einzelner Dateien brechen den Lauf nicht mehr ab

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 20:38:45 +01:00
755f1b7e0d feat(accounting): Kostenstellen von Eingangsbelegen jederzeit nachträglich ändern
Eigener Endpunkt und MCP-Tool ändern ausschließlich die Positionskostenstellen,
auch bei gebuchten oder einer Bankbuchung zugewiesenen Eingangsbelegen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 18:50:36 +01:00
c6fd53307c feat(mcp): Belegvorbereitung für Eingangsrechnungen anstoßen
- accounting.incoming_invoices.prepare startet die Vorbereitung für alle offenen oder ausgewählte Dateien und liefert angelegte Entwürfe samt Validierung sowie übersprungene Dateien
- Vorbereitungslauf meldet sein Ergebnis und läuft je Mandant nacheinander, damit parallele Aufrufe keine doppelten Belege anlegen

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:03:36 +01:00
0777315d1d feat(mcp): Archivierte Einträge wiederherstellen und Kontostände abfragen
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m21s
- archive.entities.list / archive.entities.restore für alle per MCP archivierbaren Entitäten mit Berechtigungsprüfung je Entität und Historieneintrag
- accounting.bank_accounts.balances liefert Stände von Bankkonten und Kassenbüchern, optional zu einem Stichtag

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 14:28:05 +01:00
9676a270da Merge pull request 'dev' (#251) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m7s
Build and Release FEDEO / build-and-release (push) Successful in 6m36s
Reviewed-on: #251
2026-10-25 13:17:14 +00:00
36dffc057a fix(release): exclude merged pre-baseline mobile history from commit checks
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m6s
Build and Release FEDEO / build-and-release (push) Successful in 6m25s
Merge 7a1986bb brought in the old mobile branch (89185b4b), whose commits
predate the release baseline and do not follow Conventional Commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 13:55:48 +01:00
ea31a15eac fix(accounting): Kostenstellen-Auswertung wertet Einnahmen positiv und Ausgaben negativ
Some checks failed
Build and Release FEDEO / verify (push) Failing after 1m9s
Build and Release FEDEO / build-and-release (push) Has been skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:47:34 +01:00
f0775ad52a fix(mobile): Chat startet bei neuester Nachricht und Chats nach letzter Aktivität sortieren
Der Verlauf nutzt eine invertierte Liste und öffnet dadurch immer ganz unten.
Raumlisten liefern lastActivityAt; die App sortiert alle Chats danach und
zieht einen Chat nach dem Senden sofort nach oben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 13:40:09 +02:00
e0a94f5e29 fix(mobile): KI-AGENT: Neu angelegte Chats vor veralteten Listen schützen 2026-10-19 11:44:07 +02:00
da84e98b8a feat(mobile): KI-AGENT: Föderierte Chats und gemeinsame Sichtbarkeit ergänzen 2026-10-19 11:40:26 +02:00
bfba29b15e build(ios): KI-AGENT: TestFlight-Buildnummer auf 15 erhöhen 2026-10-19 11:09:59 +02:00
7a1986bbb8 chore(sync): KI-AGENT: Aktuellen Entwicklungsstand für TestFlight übernehmen 2026-10-19 11:09:09 +02:00
f3b4fac4c0 Merge pull request 'dev' (#247) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m25s
Reviewed-on: #247
2026-10-15 16:47:32 +00:00
f56a07f8e3 feat(chat): add sidebar search for conversations
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m2s
Build and Release FEDEO / build-and-release (push) Successful in 6m4s
2026-10-15 17:54:22 +02:00
7b61857fb8 feat(notifications): include federated chat messages and invitations 2026-10-15 17:50:38 +02:00
697f538b6e feat(chat): preserve message drafts in session temp store 2026-10-15 17:45:17 +02:00
e933782731 feat(events): restore responsive appointment overview and creation
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m3s
Build and Release FEDEO / build-and-release (push) Successful in 6m11s
2026-10-15 17:36:44 +02:00
ee8f16056f feat(events): search projects by project number 2026-10-15 17:24:00 +02:00
683be7761d feat(events): add color picker to appointment editor 2026-10-15 17:22:17 +02:00
42242390f3 fix(notifications): expose preferences in settings navigation
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m0s
Build and Release FEDEO / build-and-release (push) Successful in 6m10s
2026-10-15 17:19:58 +02:00
4ffafef3f1 feat(admin): archive users and their profiles permanently
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m5s
Build and Release FEDEO / build-and-release (push) Successful in 6m53s
2026-10-15 14:22:12 +02:00
5dc9e686fc fix(documents): restrict advance invoice creation to projects
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m8s
Build and Release FEDEO / build-and-release (push) Successful in 6m24s
2026-10-15 13:05:06 +02:00
6eea3c0687 feat(portal): configure customer portal accent color in company settings 2026-10-13 19:06:33 +02:00
a8c6c2625c Merge pull request 'dev' (#242) from dev into main
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m6s
Build and Release FEDEO / build-and-release (push) Successful in 6m31s
Reviewed-on: #242
2026-10-13 16:18:53 +00:00
5463106938 fix(portal): add invitation links and correct customer login routing
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m7s
Build and Release FEDEO / build-and-release (push) Successful in 6m24s
2026-10-13 18:13:41 +02:00
070be08207 fix(pdf): hide transfer amounts on packing slips and delivery notes 2026-10-11 17:12:35 +02:00
f4fdf6fe30 feat(mcp): add outgoing document status and calendar tools 2026-10-11 13:08:29 +02:00
899e5bb339 feat(mcp): add document inbox tools and PDF transfers 2026-10-09 19:13:42 +02:00
89185b4b08 KI-AGENT: Direkte Teltonika-Fahrtenbucharchitektur konzipiert 2026-09-12 20:05:49 +02:00
aa08f5c358 KI-AGENT: iOS-Buildnummer für TestFlight auf 14 erhöhen 2026-09-08 23:13:44 +02:00
2c1047e624 KI-AGENT: Kalender, Terminbearbeitung und Dateivorschau verbessern 2026-09-08 23:08:36 +02:00
135 changed files with 7165 additions and 832 deletions

View File

@@ -0,0 +1,3 @@
ALTER TABLE "auth_users" ADD COLUMN "archived_at" timestamp with time zone;
--> statement-breakpoint
ALTER TABLE "auth_profiles" ADD COLUMN "archived_at" timestamp with time zone;

View File

@@ -0,0 +1,7 @@
INSERT INTO notifications_event_types (event_key, display_name, description, category, allowed_channels) VALUES
('collaboration.message.new', 'Zusammenarbeit: Chatnachrichten', 'Neue Nachrichten in freigegebenen Unterräumen und föderierten Chats.', 'collaboration', '["inapp","email","push"]'),
('collaboration.room.invited', 'Zusammenarbeit: Einladungen', 'Persönliche Einladungen in freigegebene Unterräume und föderierte Chats.', 'collaboration', '["inapp","email","push"]')
ON CONFLICT (event_key) DO NOTHING;
--> statement-breakpoint
CREATE INDEX notifications_collaboration_queued ON notifications_items(created_at)
WHERE status='queued' AND event_type IN ('collaboration.message.new','collaboration.room.invited');

View File

@@ -554,6 +554,20 @@
"when": 1791244800000, "when": 1791244800000,
"tag": "0081_chat_media_reactions", "tag": "0081_chat_media_reactions",
"breakpoints": true "breakpoints": true
},
{
"idx": 79,
"version": "7",
"when": 1792022400000,
"tag": "0082_archive_users",
"breakpoints": true
},
{
"idx": 80,
"version": "7",
"when": 1792022400001,
"tag": "0083_collaboration_notifications",
"breakpoints": true
} }
] ]
} }

View File

@@ -82,6 +82,7 @@ export const authProfiles = pgTable("auth_profiles", {
address_city: text("address_city"), address_city: text("address_city"),
active: boolean("active").notNull().default(true), active: boolean("active").notNull().default(true),
archived_at: timestamp("archived_at", { withTimezone: true }),
}) })
export type AuthProfile = typeof authProfiles.$inferSelect export type AuthProfile = typeof authProfiles.$inferSelect

View File

@@ -13,6 +13,7 @@ export const authUsers = pgTable("auth_users", {
multiTenant: boolean("multi_tenant").notNull().default(true), multiTenant: boolean("multi_tenant").notNull().default(true),
must_change_password: boolean("must_change_password").notNull().default(false), must_change_password: boolean("must_change_password").notNull().default(false),
is_admin: boolean("is_admin").notNull().default(false), is_admin: boolean("is_admin").notNull().default(false),
archived_at: timestamp("archived_at", { withTimezone: true }),
updatedAt: timestamp("updated_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }),

View File

@@ -5,7 +5,9 @@ import {
text, text,
jsonb, jsonb,
timestamp, timestamp,
index,
} from "drizzle-orm/pg-core" } from "drizzle-orm/pg-core"
import { sql } from "drizzle-orm"
import { tenants } from "./tenants" import { tenants } from "./tenants"
import { authUsers } from "./auth_users" import { authUsers } from "./auth_users"
@@ -48,7 +50,8 @@ export const notificationsItems = pgTable("notifications_items", {
sentAt: timestamp("sent_at", { withTimezone: true }), sentAt: timestamp("sent_at", { withTimezone: true }),
readAt: timestamp("read_at", { withTimezone: true }), readAt: timestamp("read_at", { withTimezone: true }),
}) }, t => ({ collaborationQueued: index("notifications_collaboration_queued").on(t.createdAt)
.where(sql`${t.status}='queued' AND ${t.eventType} IN ('collaboration.message.new','collaboration.room.invited')`) }))
export type NotificationItem = typeof notificationsItems.$inferSelect export type NotificationItem = typeof notificationsItems.$inferSelect
export type NewNotificationItem = typeof notificationsItems.$inferInsert export type NewNotificationItem = typeof notificationsItems.$inferInsert

View File

@@ -71,7 +71,7 @@ export async function createMcpContext(
} }
} }
export function assertToolPermission(context: McpContext, tool: McpTool) { export function assertToolPermission(context: McpContext, tool: Pick<McpTool, "name" | "requiredPermissions">) {
if (context.isAdmin) return if (context.isAdmin) return
const allowed = tool.requiredPermissions.every((permission) => const allowed = tool.requiredPermissions.every((permission) =>
@@ -85,4 +85,3 @@ export function assertToolPermission(context: McpContext, tool: McpTool) {
) )
} }
} }

View File

@@ -0,0 +1,44 @@
import { and, eq, getTableColumns } from "drizzle-orm"
import { authProfiles, files } from "../../db/schema"
import { McpContext } from "./types"
import { validateScannerFile } from "../modules/scanner-inbox.service"
import { secrets } from "../utils/secrets"
export const MCP_FILE_LIMIT = 20 * 1024 * 1024
export const MCP_BODY_LIMIT = Math.ceil(MCP_FILE_LIMIT / 3) * 4 + 64 * 1024
export const MCP_FILE_PERMISSION = "documents.read"
// Storage paths and potentially large extracted text do not belong in list responses.
const { path: _path, extractedText: _text, ...metadata } = getTableColumns(files)
export const fileMetadataColumns = metadata
export async function assertInternalDocumentAccess(context: McpContext) {
const [profile] = await context.server.db.select({ portal: authProfiles.customer_for_portal }).from(authProfiles)
.where(and(eq(authProfiles.tenant_id, context.tenantId), eq(authProfiles.user_id, context.userId))).limit(1)
if (profile?.portal) throw Object.assign(new Error("Die Dokumentenablage ist nur intern verfügbar."), { statusCode: 403 })
}
export async function loadDocumentFile(context: McpContext, id: string) {
const [file] = await context.server.db.select().from(files)
.where(and(eq(files.id, id), eq(files.tenant, context.tenantId), eq(files.archived, false))).limit(1)
if (!file) throw Object.assign(new Error("Datei nicht gefunden oder archiviert."), { statusCode: 404 })
return file
}
export function decodeDocumentUpload(filename: string, value: unknown) {
if (typeof value !== "string" || !value.length) throw new Error("contentBase64 ist erforderlich.")
if (value.length > Math.ceil(MCP_FILE_LIMIT / 3) * 4) throw new Error("MCP-Uploads sind auf 20 MiB begrenzt.")
// Buffer.from silently accepts invalid characters; require canonical, padded Base64.
if (value.length % 4 !== 0 || /[^A-Za-z0-9+/=]/.test(value)) throw new Error("contentBase64 muss gültiges Base64 ohne Data-URL-Präfix sein.")
const body = Buffer.from(value, "base64")
if (body.toString("base64") !== value) throw new Error("contentBase64 muss gültiges Base64 sein.")
if (body.length > MCP_FILE_LIMIT) throw new Error("MCP-Uploads sind auf 20 MiB begrenzt.")
const mimeType = validateScannerFile(filename, body)
return { body, mimeType }
}
export function documentDownloadUrl(id: string) {
const path = `/api/mcp/files/${id}/content`
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
return base ? `${base}${path}` : path
}

View File

@@ -0,0 +1,93 @@
import { assertToolPermission } from "./authz"
import { assertInternalDocumentAccess, MCP_FILE_LIMIT } from "./documentFiles"
import { McpContext, McpTool } from "./types"
import { createScannerInboxService, validateScannerFile } from "../modules/scanner-inbox.service"
import { uploadIncomingInvoiceFile } from "./tools/accounting"
import { secrets } from "../utils/secrets"
export const MCP_UPLOAD_PATH = "/api/mcp/files"
export const uploadTargets = ["inbox", "incoming_invoice"] as const
export type UploadTarget = typeof uploadTargets[number]
export type McpFileUpload = {
target: UploadTarget
filename: string
body: Buffer
mimeType?: string | null
invoiceId?: number | null
prepare?: boolean
}
export function mcpUploadUrl() {
const base = secrets.API_BASE_URL?.replace(/\/+$/, "")
return base ? `${base}${MCP_UPLOAD_PATH}` : MCP_UPLOAD_PATH
}
const badRequest = (message: string, statusCode = 400) => Object.assign(new Error(message), { statusCode })
/** Reads the query options of the binary upload endpoint. */
export function parseUploadQuery(query: Record<string, unknown>) {
const target = (query.target ?? "inbox") as UploadTarget
if (!uploadTargets.includes(target)) throw badRequest(`target muss ${uploadTargets.join(" oder ")} sein.`)
let invoiceId: number | null = null
if (query.invoiceId !== undefined && query.invoiceId !== "") {
invoiceId = Number(query.invoiceId)
if (!Number.isSafeInteger(invoiceId) || invoiceId < 1) throw badRequest("invoiceId muss eine positive ganze Zahl sein.")
}
if (query.prepare !== undefined && !["true", "false"].includes(String(query.prepare))) throw badRequest("prepare muss true oder false sein.")
if (target === "inbox" && (invoiceId || query.prepare !== undefined)) throw badRequest("invoiceId und prepare gelten nur für target=incoming_invoice.")
const filename = typeof query.filename === "string" && query.filename.trim() ? query.filename.trim() : null
return { target, invoiceId, prepare: String(query.prepare) !== "false", filename }
}
export async function storeMcpFileUpload(context: McpContext, upload: McpFileUpload) {
if (!upload.body.length) throw badRequest("Die Datei ist leer.")
if (upload.body.length > MCP_FILE_LIMIT) throw badRequest("MCP-Uploads sind auf 20 MiB begrenzt.", 413)
if (upload.target === "inbox") {
assertToolPermission(context, { name: "documents.files.upload", requiredPermissions: ["documents.write"] })
await assertInternalDocumentAccess(context)
const mimeType = validateScannerFile(upload.filename, upload.body)
const result = await createScannerInboxService(context.server.db).upload(context.tenantId, context.userId, upload.filename, upload.body)
return { ...result, filename: upload.filename, mimeType, size: upload.body.length }
}
assertToolPermission(context, { name: "accounting.incoming_invoices.files.upload", requiredPermissions: ["accounting.incoming_invoices.write"] })
return uploadIncomingInvoiceFile(context, {
filename: upload.filename,
buffer: upload.body,
mimeType: upload.mimeType || "application/octet-stream",
invoiceId: upload.invoiceId,
prepare: upload.prepare,
})
}
export const uploadUrlTool: McpTool = {
name: "documents.files.upload_url",
title: "Upload-Adresse für Dateien",
description: "Liefert die HTTP-Adresse, über die Dateien ohne Base64 direkt hochgeladen werden (multipart/form-data oder Rohdaten, maximal 20 MiB). Für Uploads mit Dateizugriff, z. B. per curl, statt documents.files.upload oder accounting.incoming_invoices.files.upload verwenden.",
// Informational only; the endpoint checks the permission of the chosen target.
requiredPermissions: [],
inputSchema: { type: "object", properties: {}, additionalProperties: false },
async handler() {
const url = mcpUploadUrl()
return {
url,
method: "POST",
authentication: "Authorization: Bearer <derselbe MCP-Token>",
bodies: {
multipart: "multipart/form-data mit dem Dateifeld file; der Dateiname wird aus dem Upload übernommen.",
raw: "Dateiinhalt als Body mit passendem Content-Type (application/pdf, image/jpeg, image/png, image/tiff, application/xml oder application/octet-stream) und Query-Parameter filename.",
},
query: {
target: "inbox (Standard, Dokumentenablage, benötigt documents.write) oder incoming_invoice (Eingangsrechnung, benötigt accounting.incoming_invoices.write).",
filename: "Dateiname; bei Rohdaten erforderlich, bei multipart optional zum Überschreiben.",
invoiceId: "Nur incoming_invoice: vorhandenen Eingangsbeleg verknüpfen.",
prepare: "Nur incoming_invoice: false unterdrückt die automatische Belegvorbereitung (Standard true).",
},
response: "JSON wie beim entsprechenden Base64-Werkzeug.",
examples: [
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -F "file=@rechnung.pdf" "${url}?target=incoming_invoice"`,
`curl -H "Authorization: Bearer $FEDEO_MCP_TOKEN" -H "Content-Type: application/pdf" --data-binary @scan.pdf "${url}?filename=scan.pdf"`,
],
}
},
}

View File

@@ -2,12 +2,18 @@ import { accountingTools } from "./tools/accounting"
import { masterdataTools } from "./tools/masterdata" import { masterdataTools } from "./tools/masterdata"
import { organisationTools } from "./tools/organisation" import { organisationTools } from "./tools/organisation"
import { wikiTools } from "./tools/wiki" import { wikiTools } from "./tools/wiki"
import { documentTools } from "./tools/documents"
import { archiveTools } from "./tools/archive"
import { uploadUrlTool } from "./fileUploads"
export const mcpTools = [ export const mcpTools = [
...accountingTools, ...accountingTools,
...masterdataTools, ...masterdataTools,
...organisationTools, ...organisationTools,
...wikiTools, ...wikiTools,
...documentTools,
...archiveTools,
uploadUrlTool,
] ]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

View File

@@ -1,6 +1,6 @@
import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas" import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from "../documentSchemas"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, or } from "drizzle-orm" import { and, desc, eq, ilike, inArray, or } from "drizzle-orm"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details" import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details"
import { import {
@@ -22,11 +22,15 @@ import { saveFile } from "../../utils/files"
import { insertHistoryItem } from "../../utils/history" import { insertHistoryItem } from "../../utils/history"
import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service" import { executeManualGeneration, finishManualGeneration } from "../../modules/serialexecution.service"
import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service" import { updateOutgoingDocumentCostCentres } from "../../modules/outgoing-document-cost-centres.service"
import { updateIncomingInvoiceCostCentres } from "../../modules/incoming-invoice-cost-centres.service"
import { loadBankBalances } from "../../modules/bank-balances.service"
import { dateField } from "../masterdataFields"
import { import {
prepareStatementAllocationInput, prepareStatementAllocationInput,
statementAllocationUuidArg, statementAllocationUuidArg,
} from "../statementAllocations" } from "../statementAllocations"
import { McpTool } from "../types" import { McpContext, McpTool } from "../types"
import { outgoingStatusTools } from "./outgoingStatus"
export { prepareStatementAllocationInput } from "../statementAllocations" export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -308,6 +312,98 @@ const loadIncomingInvoiceFileDefaults = async (context: { server: any; tenantId:
} }
} }
export type IncomingInvoiceFileUpload = {
filename: string
buffer: Buffer
mimeType: string
invoiceId?: number | null
invoiceArgs?: Record<string, unknown> | null
prepare?: boolean
}
/** Shared by the Base64 tool and the binary MCP upload endpoint. */
export async function uploadIncomingInvoiceFile(context: McpContext, upload: IncomingInvoiceFileUpload) {
const { filename, buffer, mimeType } = upload
const invoiceId = upload.invoiceId || null
const invoiceArgs = upload.invoiceArgs || null
const shouldPrepare = upload.prepare !== false && !invoiceId && !invoiceArgs
if (!buffer.length) throw new Error("Datei ist leer")
if (buffer.length > MAX_MCP_UPLOAD_BYTES) throw new Error("Datei ist größer als 20 MB")
let linkedInvoice: any = null
if (invoiceId) {
const [existing] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
linkedInvoice = existing
} else if (invoiceArgs) {
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
payload.state = "Entwurf"
const [createdInvoice] = await context.server.db
.insert(incominginvoices)
.values(payload as any)
.returning()
linkedInvoice = createdInvoice
}
const defaults = await loadIncomingInvoiceFileDefaults(context)
const saved = await saveFile(
context.server,
context.tenantId,
null,
{
filename,
content: buffer,
contentType: mimeType,
},
defaults.folderId,
defaults.fileTagId,
{
incominginvoice: linkedInvoice?.id || null,
createdBy: context.userId,
updatedBy: context.userId,
updatedAt: new Date(),
}
)
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
if (shouldPrepare) {
// Only the uploaded file; other open invoice files stay for an explicit preparation run.
await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds: [saved.id] })
}
const [file] = await context.server.db
.select()
.from(files)
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
.limit(1)
if (file?.incominginvoice && !linkedInvoice) {
const [preparedInvoice] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
linkedInvoice = preparedInvoice || null
}
return {
file,
invoice: linkedInvoice,
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
prepared: Boolean(shouldPrepare && linkedInvoice),
}
}
const buildIncomingInvoicePayload = ( const buildIncomingInvoicePayload = (
args: Record<string, unknown>, args: Record<string, unknown>,
userId: string, userId: string,
@@ -423,6 +519,7 @@ const assertStatementAllocationReferences = async (context: any, payload: Record
} }
export const accountingTools: McpTool[] = [ export const accountingTools: McpTool[] = [
...outgoingStatusTools,
{ {
name: "accounting.outgoing_documents.tax_types.list", name: "accounting.outgoing_documents.tax_types.list",
title: "Steuertypen für Ausgangsbelege auflisten", title: "Steuertypen für Ausgangsbelege auflisten",
@@ -1009,7 +1106,7 @@ export const accountingTools: McpTool[] = [
{ {
name: "accounting.incoming_invoices.files.upload", name: "accounting.incoming_invoices.files.upload",
title: "Datei für Eingangsbeleg hochladen", title: "Datei für Eingangsbeleg hochladen",
description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung an.", description: "Lädt eine PDF- oder Bilddatei als Base64 hoch, verknüpft sie optional mit einem Eingangsbeleg oder stößt die automatische Vorbereitung dieser Datei an. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url (target=incoming_invoice) hochladen.",
requiredPermissions: ["accounting.incoming_invoices.write"], requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: { inputSchema: {
type: "object", type: "object",
@@ -1045,83 +1142,14 @@ export const accountingTools: McpTool[] = [
const filename = stringArg(args, "filename") const filename = stringArg(args, "filename")
if (!filename) throw new Error("filename ist erforderlich") if (!filename) throw new Error("filename ist erforderlich")
const buffer = base64BufferArg(args, "contentBase64") return uploadIncomingInvoiceFile(context, {
const mimeType = stringArg(args, "mimeType") || "application/octet-stream" filename,
const invoiceId = numberArg(args, "invoiceId") buffer: base64BufferArg(args, "contentBase64"),
const invoiceArgs = optionalObjectArg(args, "invoice") as Record<string, unknown> | null mimeType: stringArg(args, "mimeType") || "application/octet-stream",
const shouldPrepare = args.prepare !== false && !invoiceId && !invoiceArgs invoiceId: numberArg(args, "invoiceId"),
invoiceArgs: optionalObjectArg(args, "invoice") as Record<string, unknown> | null,
let linkedInvoice: any = null prepare: args.prepare !== false,
})
if (invoiceId) {
const [existing] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, invoiceId), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
if (!existing) throw new Error("Eingangsbeleg nicht gefunden")
linkedInvoice = existing
} else if (invoiceArgs) {
const payload = buildIncomingInvoicePayload(invoiceArgs, context.userId, context.tenantId, true)
payload.state = "Entwurf"
const [createdInvoice] = await context.server.db
.insert(incominginvoices)
.values(payload as any)
.returning()
linkedInvoice = createdInvoice
}
const defaults = await loadIncomingInvoiceFileDefaults(context)
const saved = await saveFile(
context.server,
context.tenantId,
null,
{
filename,
content: buffer,
contentType: mimeType,
},
defaults.folderId,
defaults.fileTagId,
{
incominginvoice: linkedInvoice?.id || null,
createdBy: context.userId,
updatedBy: context.userId,
updatedAt: new Date(),
}
)
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
if (shouldPrepare) {
await context.server.services.prepareIncomingInvoices.run(context.tenantId)
}
const [file] = await context.server.db
.select()
.from(files)
.where(and(eq(files.id, saved.id), eq(files.tenant, context.tenantId)))
.limit(1)
if (file?.incominginvoice && !linkedInvoice) {
const [preparedInvoice] = await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.id, file.incominginvoice), eq(incominginvoices.tenant, context.tenantId)))
.limit(1)
linkedInvoice = preparedInvoice || null
}
return {
file,
invoice: linkedInvoice,
validation: linkedInvoice ? validateIncomingInvoiceData(linkedInvoice as Record<string, any>) : null,
prepared: Boolean(shouldPrepare && linkedInvoice),
}
}, },
}, },
{ {
@@ -1150,6 +1178,62 @@ export const accountingTools: McpTool[] = [
return validateIncomingInvoiceData(rows[0] as Record<string, any>) return validateIncomingInvoiceData(rows[0] as Record<string, any>)
}, },
}, },
{
name: "accounting.incoming_invoices.prepare",
title: "Belegvorbereitung starten",
description: "Startet die Belegvorbereitung wie der Button „Belege vorbereiten“: Dateien vom Typ Eingangsrechnung ohne verknüpften Beleg werden ausgelesen (strukturierte E-Rechnung, sonst KI) und als Eingangsbeleg-Entwurf angelegt. Optional nur für bestimmte Datei-IDs. Kann je Datei einige Sekunden dauern.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
properties: {
fileIds: {
type: "array",
items: { type: "string", format: "uuid" },
minItems: 1,
maxItems: 50,
description: "Optionale Datei-IDs; ohne Angabe alle offenen Eingangsrechnungsdateien.",
},
},
additionalProperties: false,
},
async handler(context, args) {
let fileIds: string[] | undefined
if (args.fileIds !== undefined) {
if (!Array.isArray(args.fileIds) || !args.fileIds.length || args.fileIds.length > 50) {
throw new Error("fileIds muss 1 bis 50 Datei-IDs enthalten")
}
if (args.fileIds.some((id) => typeof id !== "string" || !UUID_PATTERN.test(id))) {
throw new Error("fileIds darf nur gültige UUIDs enthalten")
}
fileIds = [...new Set((args.fileIds as string[]).map((id) => id.toLowerCase()))]
}
const result = await context.server.services.prepareIncomingInvoices.run(context.tenantId, { fileIds })
if (result.error && !result.filesFound) throw new Error(result.error)
const invoiceIds = result.created.map((entry) => entry.invoiceId)
const invoices = invoiceIds.length
? await context.server.db
.select()
.from(incominginvoices)
.where(and(eq(incominginvoices.tenant, context.tenantId), inArray(incominginvoices.id, invoiceIds)))
: []
const handled = new Set([...result.created, ...result.skipped].map((entry) => entry.fileId))
return {
filesFound: result.filesFound,
created: result.created.map((entry) => {
const invoice = invoices.find((row) => row.id === entry.invoiceId) || null
return { ...entry, invoice, validation: invoice ? validateIncomingInvoiceData(invoice as Record<string, any>) : null }
}),
skipped: [
...result.skipped,
...(fileIds || []).filter((id) => !handled.has(id))
.map((fileId) => ({ fileId, reason: "Keine offene Eingangsrechnungsdatei (nicht gefunden, archiviert, anderer Dateityp oder bereits vorbereitet)" })),
],
}
},
},
{ {
name: "accounting.incoming_invoices.create", name: "accounting.incoming_invoices.create",
title: "Eingangsbeleg erstellen", title: "Eingangsbeleg erstellen",
@@ -1227,6 +1311,44 @@ export const accountingTools: McpTool[] = [
return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) } return { invoice: updated, validation: validateIncomingInvoiceData(updated as Record<string, any>) }
}, },
}, },
{
name: "accounting.incoming_invoices.cost_centres.update",
title: "Kostenstellen eines Eingangsbelegs ändern",
description: "Ändert ausschließlich die Positionskostenstellen eines Eingangsbelegs, auch wenn er gebucht oder einer Bankbuchung zugewiesen ist.",
requiredPermissions: ["accounting.incoming_invoices.write"],
inputSchema: {
type: "object",
required: ["id", "accountCostCentres"],
properties: {
id: { type: "number" },
accountCostCentres: {
type: "array",
minItems: 1,
items: {
type: "object",
required: ["index", "costcentre"],
properties: {
index: { type: "integer", minimum: 0, description: "Nullbasierter Index der Position in accounts." },
costcentre: { type: ["string", "null"], description: "UUID der Positionskostenstelle oder null." },
},
},
},
},
},
async handler(context, args) {
const id = numberArg(args, "id")
if (!id) throw new Error("id ist erforderlich")
const invoice = await updateIncomingInvoiceCostCentres(
context.server,
context.tenantId,
context.userId,
id,
{ accountCostCentres: args.accountCostCentres as any },
)
return { invoice }
},
},
{ {
name: "accounting.incoming_invoices.book", name: "accounting.incoming_invoices.book",
title: "Eingangsbeleg buchen", title: "Eingangsbeleg buchen",
@@ -1308,6 +1430,26 @@ export const accountingTools: McpTool[] = [
return { invoice: updated } return { invoice: updated }
}, },
}, },
{
name: "accounting.bank_accounts.balances",
title: "Kontostände abfragen",
description: "Liefert die Kontostände der Bankkonten und Kassenbücher des aktiven Mandanten samt Summe. Bankkonten zeigen den zuletzt synchronisierten Buchungssaldo; mit asOf wird der Stand zu einem vergangenen Datum aus den Bankumsätzen zurückgerechnet. Kassenbücher ergeben sich aus Anfangsbestand und Buchungen.",
requiredPermissions: ["accounting.bank.read"],
inputSchema: {
type: "object",
properties: {
account: { type: "number", description: "Optionale Bankkonto-ID; ohne Angabe alle Konten." },
asOf: { ...dateField.schema, description: "Optionaler Stichtag YYYY-MM-DD (Stand zum Tagesende)." },
},
},
async handler(context, args) {
const account = numberArg(args, "account")
const asOf = args.asOf === undefined || args.asOf === null ? null : dateField.parse(args.asOf, "asOf")
const result = await loadBankBalances(context.server, context.tenantId, { account, asOf })
if (account && !result.accounts.length) throw new Error("Bankkonto nicht gefunden")
return result
},
},
{ {
name: "accounting.bank_statements.list", name: "accounting.bank_statements.list",
title: "Bankumsätze auflisten", title: "Bankumsätze auflisten",

View File

@@ -0,0 +1,150 @@
import { and, desc, eq, getTableColumns, ilike, or } from "drizzle-orm"
import * as tables from "../../../db/schema"
import { assertToolPermission } from "../authz"
import { assertInternalDocumentAccess } from "../documentFiles"
import { idField, InputField, numericField, objectField, textField, uuidField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
import { insertHistoryItem } from "../../utils/history"
import { recalculateServicePricesForTenant } from "../../modules/service-price-recalculation.service"
type ArchiveEntity = {
label: string
table: any
/** Key used by the history log and the generic resource routes. */
resource: string
permission: string
searchColumns: string[]
uuid?: boolean
internalDocuments?: boolean
}
const masterdata = (label: string, table: any, resource: string, namespace: string, searchColumns: string[], uuid = false): ArchiveEntity =>
({ label, table, resource, permission: `masterdata.${namespace}`, searchColumns, uuid })
/** Every entity the MCP can archive; restore checks the matching write permission. */
export const archiveEntities: Record<string, ArchiveEntity> = {
customers: masterdata("Kunde", tables.customers, "customers", "customers", ["name", "customerNumber"]),
vendors: masterdata("Lieferant", tables.vendors, "vendors", "vendors", ["name", "vendorNumber"]),
contacts: masterdata("Kontakt", tables.contacts, "contacts", "contacts", ["fullName", "email"]),
products: masterdata("Artikel", tables.products, "products", "products", ["name", "article_number"]),
services: masterdata("Leistung", tables.services, "services", "services", ["name"]),
cost_centres: masterdata("Kostenstelle", tables.costcentres, "costcentres", "cost_centres", ["name", "number"], true),
branches: masterdata("Niederlassung", tables.branches, "branches", "branches", ["name", "number"]),
teams: masterdata("Team", tables.teams, "teams", "teams", ["name"]),
vehicles: masterdata("Fahrzeug", tables.vehicles, "vehicles", "vehicles", ["name", "license_plate"]),
inventory_items: masterdata("Inventar", tables.inventoryitems, "inventoryitems", "inventory_items", ["name", "articleNumber"]),
projects: { label: "Projekt", table: tables.projects, resource: "projects", permission: "organisation.projects", searchColumns: ["name", "projectNumber"] },
events: { label: "Termin", table: tables.events, resource: "events", permission: "organisation.events", searchColumns: ["name"] },
tasks: { label: "Aufgabe", table: tables.tasks, resource: "tasks", permission: "organisation.tasks", searchColumns: ["name"] },
outgoing_documents: { label: "Ausgangsbeleg", table: tables.createddocuments, resource: "createddocuments", permission: "accounting.outgoing_documents", searchColumns: ["documentNumber", "title"] },
incoming_invoices: { label: "Eingangsbeleg", table: tables.incominginvoices, resource: "incominginvoices", permission: "accounting.incoming_invoices", searchColumns: ["reference", "description"] },
files: { label: "Datei", table: tables.files, resource: "files", permission: "documents", searchColumns: ["name"], uuid: true, internalDocuments: true },
}
const entityField: InputField = {
schema: { type: "string", enum: Object.keys(archiveEntities), description: "Art des archivierten Datensatzes." },
parse(value, path) {
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(archiveEntities, value)) throw new Error(`${path}: unbekannte Entität`)
return value
},
}
const entityIdField: InputField = {
schema: { type: ["integer", "string"], description: "ID des Datensatzes; UUID bei cost_centres und files, sonst ganze Zahl." },
parse: (value) => value,
}
const searchField: InputField = {
schema: { type: "string", maxLength: 200, description: "Optionaler Suchtext, z. B. Name oder Nummer." },
parse(value, path) {
const text = textField().parse(value, path)
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
return text
},
}
const listInput = objectField({
entity: entityField,
search: searchField,
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
})
const restoreInput = objectField({ entity: entityField, id: entityIdField })
const columnKey = (table: any, ...names: string[]) => names.find((name) => name in getTableColumns(table))
/** asToolResult hides records flagged archived; archived rows are marked explicitly instead. */
const withoutArchivedFlag = ({ archived: _archived, path: _path, extractedText: _text, ...row }: Record<string, any>) => row
async function authorize(context: McpContext, tool: McpTool, entity: ArchiveEntity, access: "read" | "write") {
assertToolPermission(context, { name: tool.name, requiredPermissions: [`${entity.permission}.${access}`] })
if (entity.internalDocuments) await assertInternalDocumentAccess(context)
}
export function prepareArchiveRestoreInput(args: Record<string, unknown>) {
const input = restoreInput.parse(args, "arguments")
if (!input.entity) throw new Error("entity ist erforderlich")
if (input.id === undefined || input.id === null) throw new Error("id ist erforderlich")
const entity = archiveEntities[input.entity]
return { key: input.entity as string, entity, id: (entity.uuid ? uuidField : idField).parse(input.id, "id") }
}
const listTool: McpTool = {
name: "archive.entities.list",
title: "Archivierte Einträge auflisten",
description: "Listet archivierte Datensätze einer Entitätsart im aktiven Mandanten, zuletzt geänderte zuerst. IDs anschließend mit archive.entities.restore wiederherstellen.",
// Checked per entity in the handler.
requiredPermissions: [],
inputSchema: { ...listInput.schema, required: ["entity"] },
async handler(context, args) {
const input = listInput.parse(args, "arguments")
if (!input.entity) throw new Error("entity ist erforderlich")
const entity = archiveEntities[input.entity]
await authorize(context, listTool, entity, "read")
const table = entity.table
const conditions: any[] = [eq(table.tenant, context.tenantId), eq(table.archived, true)]
if (input.search) {
const pattern = `%${input.search.replace(/[\\%_]/g, "\\$&")}%`
conditions.push(or(...entity.searchColumns.map((column) => ilike(table[column], pattern))))
}
const updatedAt = columnKey(table, "updatedAt", "updated_at")!
const rows = await context.server.db.select().from(table).where(and(...conditions))
.orderBy(desc(table[updatedAt]), desc(table.id)).limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
const result = paginatedResult(rows, input)
return { ...result, entity: input.entity, rows: result.rows.map((row: any) => ({ ...withoutArchivedFlag(row), isArchived: true })) }
},
}
const restoreTool: McpTool = {
name: "archive.entities.restore",
title: "Archivierten Eintrag wiederherstellen",
description: `Stellt einen archivierten Datensatz im aktiven Mandanten wieder her (entity: ${Object.keys(archiveEntities).join(", ")}). Benötigt die Schreibberechtigung der jeweiligen Entität.`,
requiredPermissions: [],
readOnly: false,
inputSchema: { ...restoreInput.schema, required: ["entity", "id"] },
async handler(context, args) {
const { key, entity, id } = prepareArchiveRestoreInput(args)
await authorize(context, restoreTool, entity, "write")
const table = entity.table
return context.server.db.transaction(async (tx) => {
const server = { ...context.server, db: tx } as unknown as McpContext["server"]
const scope = and(eq(table.id, id), eq(table.tenant, context.tenantId))
const [existing] = await tx.select().from(table).where(scope).limit(1).for("update")
if (!existing) throw new Error(`${entity.label} nicht gefunden`)
if (!existing.archived) throw new Error(`${entity.label} ist nicht archiviert`)
const [result] = await tx.update(table).set({
archived: false,
[columnKey(table, "updatedAt", "updated_at")!]: new Date(),
[columnKey(table, "updatedBy", "updated_by")!]: context.userId,
}).where(scope).returning()
if (["products", "services"].includes(entity.resource)) {
await recalculateServicePricesForTenant(server, context.tenantId, context.userId)
}
await insertHistoryItem(server, {
tenant_id: context.tenantId, created_by: context.userId, entity: entity.resource, entityId: result.id,
action: "restored", oldVal: existing, newVal: result,
})
return { entity: key, id: result.id, success: true, action: "restored", record: withoutArchivedFlag(result) }
})
},
}
export const archiveTools: McpTool[] = [listTool, restoreTool]

View File

@@ -0,0 +1,166 @@
import { and, desc, eq, ilike, inArray, isNull, lt, or, sql } from "drizzle-orm"
import { authProfiles, customers, events, inventoryitemgroups, inventoryitems, projects, tenants, vehicles, vendors } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history"
import { arrayField, booleanField, dateField, idField, InputField, nullable, objectField, textField, uuidField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
export const eventStates = ["Entwurf", "Final"]
export const eventRepeatIntervals = ["Keine Wiederholung", "Täglich", "Wöchentlich", "2-wöchentlich", "Monatlich", "Jährlich"]
const enumField = (values: string[]): InputField => ({
schema: { type: "string", enum: values },
parse(value, path) {
if (typeof value !== "string" || !values.includes(value)) throw new Error(`${path}: ungültiger Wert`)
return value
},
})
export const eventDateTimeField: InputField = {
schema: { type: "string", format: "date-time", description: "ISO-Zeitpunkt mit Zeitzone, z. B. 2026-10-15T09:00:00+02:00." },
parse(value, path) {
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)) {
throw new Error(`${path}: ISO-Zeitpunkt mit Zeitzone erwartet`)
}
dateField.parse(value.slice(0, 10), path)
if (Number(value.slice(11, 13)) > 23 || Number(value.slice(14, 16)) > 59 || Number(value.slice(17, 19)) > 59
|| !Number.isFinite(Date.parse(value))) throw new Error(`${path}: ungültiger Zeitpunkt`)
return new Date(value)
},
}
const idsField = (field: InputField): InputField => ({
schema: { ...arrayField(field).schema, maxItems: 1000, uniqueItems: true },
parse(value, path) {
if (!Array.isArray(value) || value.length > 1000) throw new Error(`${path}: maximal 1000 IDs erwartet`)
return [...new Set(arrayField(field).parse(value, path))]
},
})
const eventFields = {
name: textField(true), startDate: eventDateTimeField, endDate: nullable(eventDateTimeField),
eventtype: nullable(textField(true)), quick: booleanField, state: enumField(eventStates), color: nullable(textField()),
repeatInterval: enumField(eventRepeatIntervals), notes: nullable(textField()), link: nullable(textField()),
project: nullable(idField), customer: nullable(idField), vendor: nullable(idField),
profiles: idsField(uuidField), vehicles: idsField(idField), inventoryitems: idsField(idField), inventoryitemgroups: idsField(uuidField),
}
const listFields = {
query: textField(), project: idField, customer: idField, vendor: idField, eventtype: textField(true),
from: eventDateTimeField, to: eventDateTimeField, state: enumField(eventStates), quick: booleanField,
repeatInterval: enumField(eventRepeatIntervals), profile: uuidField, vehicle: idField, inventoryitem: idField, inventoryitemgroup: uuidField,
includeRecurring: { ...booleanField, schema: { type: "boolean", default: true, description: "Bei from auch früher begonnene Wiederholungsserien liefern. Das Ergebnis enthält Serien-Stammsätze, keine einzelnen Vorkommen." } },
limit: { schema: paginationProperties.limit, parse: value => limitFromArgs({ limit: value }) },
offset: { schema: paginationProperties.offset, parse: value => offsetFromArgs({ offset: value }) },
}
const eventScope = (tenantId: number, id: number) => and(eq(events.id, id), eq(events.tenant, tenantId), eq(events.archived, false))
export function prepareEventInput(action: "create" | "update" | "archive", args: Record<string, unknown>) {
const input = objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).parse(args, "arguments")
for (const key of action === "create" ? ["name", "startDate"] : ["id"]) {
if (input[key] === undefined) throw new Error(`${key} ist erforderlich`)
}
if (action === "update" && Object.keys(input).length === 1) throw new Error("Mindestens ein bearbeitbares Feld ist erforderlich")
return input
}
async function validateEventReferences(db: any, tenantId: number, input: Record<string, any>) {
const references = [
["project", projects], ["customer", customers], ["vendor", vendors], ["profiles", authProfiles],
["vehicles", vehicles], ["inventoryitems", inventoryitems], ["inventoryitemgroups", inventoryitemgroups],
] as const
for (const [field, table] of references) {
const value = input[field]
const ids = value == null ? [] : Array.isArray(value) ? value : [value]
if (!ids.length) continue
const scope = table === authProfiles
? and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.customer_for_portal))
: and(eq((table as any).tenant, tenantId), eq((table as any).archived, false))
const found = await db.select({ id: table.id }).from(table).where(and(inArray(table.id, ids), scope)).for("share")
if (found.length !== ids.length) throw new Error(`${field}: verknüpfter Datensatz im aktiven Mandanten nicht verfügbar`)
}
}
async function mutateEvent(context: McpContext, action: "create" | "update" | "archive", args: Record<string, unknown>) {
const { id, ...input } = prepareEventInput(action, args)
return context.server.db.transaction(async tx => {
let existing: typeof events.$inferSelect | undefined
if (action !== "create") {
;[existing] = await tx.select().from(events).where(eventScope(context.tenantId, id)).limit(1).for("update")
if (!existing) throw new Error("Termin nicht gefunden oder archiviert")
}
const merged = { ...existing, ...input }
if (action !== "archive") {
if (merged.endDate && new Date(merged.endDate).getTime() < new Date(merged.startDate).getTime()) {
throw new Error("endDate darf nicht vor startDate liegen")
}
await validateEventReferences(tx, context.tenantId, input)
}
const change = { ...(action === "archive" ? { archived: true } : input), updatedAt: new Date(), updatedBy: context.userId }
const [event] = action === "create"
? await tx.insert(events).values({ ...change, tenant: context.tenantId, name: input.name, startDate: input.startDate }).returning()
: await tx.update(events).set(change).where(eventScope(context.tenantId, id)).returning()
await insertHistoryItem({ ...context.server, db: tx } as any, { tenant_id: context.tenantId, created_by: context.userId,
entity: "events", entityId: event.id, action: action === "create" ? "created" : action === "archive" ? "archived" : "updated",
oldVal: existing, newVal: event })
return action === "archive" ? { id: event.id, success: true, action: "archived" } : { event }
})
}
export const calendarTools: McpTool[] = [
{
name: "organisation.calendar.options", title: "Kalenderoptionen laden",
description: "Lädt die konfigurierten Terminarten des Mandanten sowie erlaubte Status und Wiederholungen.",
requiredPermissions: ["organisation.events.read"], inputSchema: objectField({}).schema,
async handler(context, args) {
objectField({}).parse(args, "arguments")
const [tenant] = await context.server.db.select({ calendarConfig: tenants.calendarConfig }).from(tenants).where(eq(tenants.id, context.tenantId)).limit(1)
if (!tenant) throw new Error("Mandant nicht gefunden")
return { calendarConfig: tenant.calendarConfig, states: eventStates, repeatIntervals: eventRepeatIntervals }
},
},
{
name: "organisation.events.list", title: "Kalendertermine auflisten",
description: "Listet aktive Termine und Serien-Stammsätze mit Text-, Zeitraum-, Status- und Ressourcenfiltern. from/to begrenzen Überlappungen (to exklusiv); früher begonnene Serien werden standardmäßig mitgeliefert, ohne einzelne Wiederholungen auszurechnen.",
requiredPermissions: ["organisation.events.read"], inputSchema: objectField(listFields).schema,
async handler(context, args) {
const input = objectField(listFields).parse(args, "arguments")
if (input.from && input.to && input.from >= input.to) throw new Error("from muss vor to liegen")
const conditions = [eq(events.tenant, context.tenantId), eq(events.archived, false)]
if (input.query) {
const query = `%${input.query.replace(/[\\%_]/g, "\\$&")}%`
conditions.push(or(ilike(events.name, query), ilike(events.notes, query), ilike(events.link, query)))
}
for (const key of ["project", "customer", "vendor", "eventtype", "state", "quick", "repeatInterval"]) {
if (input[key] !== undefined) conditions.push(eq(events[key], input[key]))
}
for (const [key, column] of [["profile", events.profiles], ["vehicle", events.vehicles], ["inventoryitem", events.inventoryitems], ["inventoryitemgroup", events.inventoryitemgroups]] as const) {
if (input[key] !== undefined) conditions.push(sql`${column} @> ${JSON.stringify([input[key]])}::jsonb`)
}
if (input.to) conditions.push(lt(events.startDate, input.to))
if (input.from) conditions.push(or(
sql`${events.endDate} > ${input.from.toISOString()}::timestamptz`,
sql`${events.startDate} >= ${input.from.toISOString()}::timestamptz`,
input.includeRecurring !== false ? inArray(events.repeatInterval, eventRepeatIntervals.slice(1)) : undefined,
))
const rows = await context.server.db.select().from(events).where(and(...conditions)).orderBy(desc(events.startDate), events.id)
.limit(limitFromArgs(input) + 1).offset(offsetFromArgs(input))
return paginatedResult(rows, input)
},
},
{
name: "organisation.events.get", title: "Kalendertermin laden", description: "Lädt einen aktiven Termin einschließlich Wiederholung und Ressourcenzuordnungen.",
requiredPermissions: ["organisation.events.read"], inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
async handler(context, args) {
const input = prepareEventInput("archive", args)
const [event] = await context.server.db.select().from(events).where(eventScope(context.tenantId, input.id)).limit(1)
if (!event) throw new Error("Termin nicht gefunden oder archiviert")
return { event }
},
},
...(["create", "update", "archive"] as const).map(action => ({
name: `organisation.events.${action}`, title: action === "create" ? "Kalendertermin erstellen" : action === "update" ? "Kalendertermin bearbeiten" : "Kalendertermin archivieren",
description: action === "create" ? "Erstellt einen Termin, standardmäßig Final und ohne Wiederholung. Zeitpunkte benötigen eine explizite Zeitzone."
: action === "update" ? "Ändert nur angegebene Felder. Ressourcenlisten werden ersetzt; null leert optionale Felder. Bei Wiederholungen wird die gesamte Serie geändert."
: "Archiviert einen Termin bzw. seine gesamte Wiederholungsserie.",
requiredPermissions: ["organisation.events.write"],
inputSchema: { ...objectField({ ...(action === "archive" ? {} : eventFields), ...(action === "create" ? {} : { id: idField }) }).schema,
required: action === "create" ? ["name", "startDate"] : ["id"] },
handler: (context: McpContext, args: Record<string, unknown>) => mutateEvent(context, action, args),
})),
]

View File

@@ -0,0 +1,148 @@
import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"
import { files, filetags, folders, scannerInboxDocuments } from "../../../db/schema"
import { assignmentKinds, assignmentOptions, assignmentTargets, FileAssignments } from "../../modules/scanner-inbox-assignments"
import { createScannerInboxService, ScannerInboxService } from "../../modules/scanner-inbox.service"
import { loadFileBuffer } from "../../utils/fileBuffer"
import { assertInternalDocumentAccess, decodeDocumentUpload, documentDownloadUrl, fileMetadataColumns, loadDocumentFile, MCP_FILE_LIMIT } from "../documentFiles"
import { InputField, idField, nullable, numericField, objectField, textField, uuidField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
const searchField: InputField = {
schema: { type: "string", maxLength: 200, description: "Suchtext im Dateinamen bzw. Namen." },
parse(value, path) {
const text = textField().parse(value, path)
if (text.length > 200) throw new Error(`${path}: maximal 200 Zeichen`)
return text
},
}
const filenameField: InputField = {
schema: { type: "string", minLength: 1, maxLength: 200 },
parse: (value, path) => textField(true).parse(value, path),
}
const paginationFields = {
limit: { ...numericField(true, 1, 100), schema: paginationProperties.limit },
offset: { ...numericField(true, 0), schema: paginationProperties.offset },
}
const assignmentFields = Object.fromEntries(Object.entries(assignmentTargets).map(([key, target]) =>
[key, nullable(target.uuid ? uuidField : idField)]))
const assignmentsField = objectField(assignmentFields)
const kindField: InputField = {
schema: { type: "string", enum: Object.keys(assignmentTargets) },
parse(value) {
if (typeof value !== "string" || !Object.prototype.hasOwnProperty.call(assignmentTargets, value)) throw new Error("Unbekannte Zuordnungsart.")
return value
},
}
const formatField: InputField = {
schema: { type: "string", enum: ["url", "base64"], default: "url" },
parse(value) {
if (value !== "url" && value !== "base64") throw new Error("format muss url oder base64 sein.")
return value
},
}
const like = (value: string) => `%${value.replace(/[\\%_]/g, "\\$&")}%`
type Dependencies = {
inbox?: (context: McpContext) => ScannerInboxService
readFile?: typeof loadFileBuffer
}
export function createDocumentTools(dependencies: Dependencies = {}): McpTool[] {
const inbox = dependencies.inbox || ((context: McpContext) => createScannerInboxService(context.server.db))
const readFile = dependencies.readFile || loadFileBuffer
const tool = (name: string, title: string, description: string, write: boolean, fields: Record<string, InputField>,
required: string[], handler: McpTool["handler"]): McpTool => ({
name: `documents.${name}`, title, description,
requiredPermissions: [write ? "documents.write" : "documents.read"],
inputSchema: { ...objectField(fields).schema, required },
async handler(context, args) {
const input = objectField(fields).parse(args, "arguments")
for (const key of required) if (input[key] === undefined) throw new Error(`${key} ist erforderlich.`)
await assertInternalDocumentAccess(context)
return handler(context, input)
},
})
return [
tool("inbox.list", "Dokumentenablage auflisten", "Listet offene Dokumente der Dokumentenablage mit Status der KI-Ablagevorschläge. id ist die Datei-UUID.", false,
{ search: searchField, ...paginationFields }, [], async (context, args) => {
const rows = await context.server.db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt,
suggestionStatus: scannerInboxDocuments.suggestionStatus })
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
.where(and(eq(scannerInboxDocuments.tenantId, context.tenantId), eq(files.tenant, context.tenantId),
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false),
args.search ? ilike(scannerInboxDocuments.remoteFilename, like(args.search as string)) : undefined))
.orderBy(desc(scannerInboxDocuments.receivedAt), desc(files.id)).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
return paginatedResult(rows, args)
}),
tool("inbox.count", "Offene Dokumente zählen", "Zählt nicht archivierte, noch nicht abgelegte Dokumente des Mandanten.", false, {}, [],
context => inbox(context).openCount(context.tenantId)),
...([ ["folders", folders], ["types", filetags] ] as const).map(([name, table]) =>
tool(`${name}.list`, name === "folders" ? "Ablageordner auflisten" : "Dokumenttypen auflisten",
"Listet verfügbare Ablageziele. Ordner enthalten parent, standardFiletype und standardFiletypeIsOptional.", false,
{ search: searchField, ...paginationFields }, [], async (context, args) => {
const rows = await context.server.db.select().from(table)
.where(and(eq(table.tenant, context.tenantId), eq(table.archived, false), args.search ? ilike(table.name, like(args.search as string)) : undefined))
.orderBy(asc(table.name), asc(table.id)).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
return paginatedResult(rows, args)
})),
tool("assignment_kinds.list", "Zuordnungsarten auflisten", "Zeigt die unterstützten Zuordnungsarten, beispielsweise Projekt, Kunde, Eingangsrechnung oder Ausgangsbeleg.", false, {}, [],
async () => ({ kinds: assignmentKinds })),
tool("assignments.search", "Zuordnungsziele suchen", "Sucht verfügbare Datensätze einer Zuordnungsart im aktiven Mandanten.", false,
{ kind: kindField, search: searchField, ...paginationFields }, ["kind"], async (context, args) =>
paginatedResult(await assignmentOptions(context.server.db, context.tenantId, args.kind as string, args.search as string || "",
limitFromArgs(args) + 1, offsetFromArgs(args)), args)),
tool("inbox.assignments.get", "Dokumentzuordnungen laden", "Lädt bestehende Zuordnungen eines offenen Dokuments aus der Dokumentenablage.", false,
{ id: uuidField }, ["id"], async (context, args) => ({ assignments: await inbox(context).getAssignments(context.tenantId, args.id as string) })),
tool("inbox.suggestion.get", "Ablagevorschlag laden", "Lädt Status und Vorschlag der KI-Erkennung. Ein Vorschlag legt das Dokument noch nicht ab.", false,
{ id: uuidField }, ["id"], (context, args) => inbox(context).getSuggestion(context.tenantId, args.id as string)),
tool("inbox.suggestion.refresh", "Ablagevorschlag neu anfordern", "Stellt die KI-Erkennung erneut in die Warteschlange. Den Status anschließend mit documents.inbox.suggestion.get abfragen.", true,
{ id: uuidField }, ["id"], (context, args) => inbox(context).queueSuggestion(context.tenantId, args.id as string)),
tool("inbox.file", "Dokument ablegen", "Legt ein offenes Dokument in einem Ordner (folder: null für Hauptebene) mit Dokumenttyp ab. Optionale Zuordnungen ändern nur angegebene Felder; null entfernt eine Zuordnung. Feste Ordnertypen werden geprüft.", true,
{ id: uuidField, folder: nullable(uuidField), type: uuidField, assignments: assignmentsField }, ["id", "folder", "type"],
(context, args) => inbox(context).fileDocument(context.tenantId, context.userId, args.id as string, args.folder as string | null,
args.type as string, args.assignments as FileAssignments)),
tool("inbox.archive", "Dokument archivieren", "Archiviert ein offenes Dokument aus der Dokumentenablage. Bereits abgelegte Dokumente werden abgewiesen.", true,
{ id: uuidField }, ["id"], async (context, args) => {
await inbox(context).archiveDocument(context.tenantId, context.userId, args.id as string)
return { id: args.id, success: true, action: "archived" }
}),
tool("files.list", "Gespeicherte Dateien suchen", "Listet nicht archivierte Dateien einschließlich PDFs zu Eingangsrechnungen und Ausgangsbelegen. Filter werden kombiniert; folder: null sucht auf der Hauptebene.", false,
{ search: searchField, folder: nullable(uuidField), type: uuidField, mimeType: textField(true), ...assignmentFields, ...paginationFields }, [],
async (context, args) => {
const conditions = [eq(files.tenant, context.tenantId), eq(files.archived, false)]
if (args.search) conditions.push(or(ilike(files.name, like(args.search as string)), ilike(files.extractedText, like(args.search as string))))
for (const key of ["folder", "type", "mimeType", ...Object.keys(assignmentFields)]) {
if (args[key] !== undefined) conditions.push(args[key] === null ? isNull(files[key]) : eq(files[key], args[key]))
}
const rows = await context.server.db.select(fileMetadataColumns).from(files).where(and(...conditions))
.orderBy(desc(files.createdAt), files.id).limit(limitFromArgs(args) + 1).offset(offsetFromArgs(args))
return paginatedResult(rows, args)
}),
tool("files.get", "Dateidetails laden", "Lädt Metadaten, Zuordnungs-IDs und bereits extrahierten Text einer Datei. Binärinhalt über documents.files.download abrufen.", false,
{ id: uuidField }, ["id"], async (context, args) => {
const { path, ...file } = await loadDocumentFile(context, args.id as string)
return { file }
}),
tool("files.download", "PDF oder Datei herunterladen", "Liefert standardmäßig eine Download-URL für die Originaldatei. GET benötigt denselben Authorization: Bearer MCP-Token. Alternativ format: base64 für Dateiinhalt bis 20 MiB direkt im Ergebnis; keine PDF-Konvertierung.", false,
{ id: uuidField, format: formatField }, ["id"], async (context, args) => {
const file = await loadDocumentFile(context, args.id as string)
if (!file.path) throw new Error("Kein Dateiinhalt verfügbar.")
const metadata = { id: file.id, filename: file.name, mimeType: file.mimeType, size: file.size }
if (args.format !== "base64") return { ...metadata, url: documentDownloadUrl(file.id), method: "GET", authentication: "Bearer" }
if (file.size > MCP_FILE_LIMIT) throw new Error("Die Datei ist für Base64 zu groß. Bitte format: url verwenden.")
const body = await readFile(file.path, MCP_FILE_LIMIT)
return { ...metadata, size: body.length, contentBase64: body.toString("base64") }
}),
tool("files.upload", "PDF oder Scan hochladen", "Lädt eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei als Base64 (maximal 20 MiB, ohne Data-URL-Präfix) in die Dokumentenablage. Mit Dateizugriff besser ohne Base64 über die Adresse aus documents.files.upload_url hochladen. Danach documents.inbox.file verwenden. Gleicher Dateiname und Inhalt werden nicht erneut angelegt; anderer Inhalt unter demselben Namen wird abgewiesen.", true,
{ filename: filenameField, contentBase64: { schema: { type: "string", minLength: 1, maxLength: Math.ceil(MCP_FILE_LIMIT / 3) * 4 }, parse: value => value } },
["filename", "contentBase64"], async (context, args) => {
const { body, mimeType } = decodeDocumentUpload(args.filename as string, args.contentBase64)
const result = await inbox(context).upload(context.tenantId, context.userId, args.filename as string, body)
return { ...result, filename: args.filename, mimeType, size: body.length }
}),
]
}
export const documentTools = createDocumentTools()

View File

@@ -1,9 +1,10 @@
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { and, desc, eq, ilike, or } from "drizzle-orm" import { and, desc, eq, ilike, or } from "drizzle-orm"
import { customers, events, plants, projects, tasks } from "../../../db/schema" import { customers, plants, projects, tasks } from "../../../db/schema"
import { insertHistoryItem } from "../../utils/history" import { insertHistoryItem } from "../../utils/history"
import { activateProjectPhase } from "../projectPhases" import { activateProjectPhase } from "../projectPhases"
import { McpTool } from "../types" import { McpTool } from "../types"
import { calendarTools } from "./calendar"
const stringArg = (args: Record<string, unknown>, key: string) => { const stringArg = (args: Record<string, unknown>, key: string) => {
@@ -17,6 +18,7 @@ const numberArg = (args: Record<string, unknown>, key: string) => {
} }
export const organisationTools: McpTool[] = [ export const organisationTools: McpTool[] = [
...calendarTools,
{ {
name: "organisation.customers.search", name: "organisation.customers.search",
title: "Kunden suchen", title: "Kunden suchen",
@@ -238,51 +240,6 @@ export const organisationTools: McpTool[] = [
return paginatedResult(rows, args) return paginatedResult(rows, args)
}, },
}, },
{
name: "organisation.events.list",
title: "Termine auflisten",
description: "Listet Termine des aktiven Mandanten mit optionalen Projekt- oder Kundenfiltern.",
requiredPermissions: ["organisation.events.read"],
inputSchema: {
type: "object",
properties: {
query: { type: "string", description: "Suchtext für Name, Notizen oder Link." },
project: { type: "number" },
customer: { type: "number" },
eventtype: { type: "string" },
...paginationProperties,
},
},
async handler(context, args) {
const conditions = [eq(events.tenant, context.tenantId)]
const query = stringArg(args, "query")
const project = numberArg(args, "project")
const customer = numberArg(args, "customer")
const eventtype = stringArg(args, "eventtype")
if (query) {
conditions.push(or(
ilike(events.name, `%${query}%`),
ilike(events.notes, `%${query}%`),
ilike(events.link, `%${query}%`)
))
}
if (project) conditions.push(eq(events.project, project))
if (customer) conditions.push(eq(events.customer, customer))
if (eventtype) conditions.push(eq(events.eventtype, eventtype))
conditions.push(eq(events.archived, false))
const rows = await context.server.db
.select()
.from(events)
.where(and(...conditions))
.orderBy(desc(events.startDate), events.id)
.limit(limitFromArgs(args) + 1)
.offset(offsetFromArgs(args))
return paginatedResult(rows, args)
},
},
{ {
name: "organisation.tasks.list", name: "organisation.tasks.list",
title: "Aufgaben auflisten", title: "Aufgaben auflisten",

View File

@@ -0,0 +1,130 @@
import { and, desc, eq, inArray, ne } from "drizzle-orm"
import { createddocuments, statementallocations } from "../../../db/schema"
import { booleanField, idField, InputField, objectField, textField } from "../masterdataFields"
import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination"
import { McpContext, McpTool } from "../types"
const paymentStates = ["Offen", "Teilbezahlt", "Bezahlt", "Überbezahlt"]
const paymentField: InputField = {
schema: { type: "string", enum: paymentStates },
parse(value) {
if (typeof value !== "string" || !paymentStates.includes(value)) throw new Error("Unbekannter Zahlungsstatus")
return value
},
}
const listFields = {
state: textField(true), type: textField(true), customer: idField, project: idField,
cancelled: booleanField, paymentStatus: paymentField,
limit: { schema: paginationProperties.limit, parse: value => limitFromArgs({ limit: value }) },
offset: { schema: paginationProperties.offset, parse: value => offsetFromArgs({ offset: value }) },
}
const money = (value: number) => Number(value.toFixed(2))
function documentAmount(document: any, advanceInvoices: any[]) {
let net = 0, tax19 = 0, tax7 = 0, advanceAmount = 0
for (const row of document.rows || []) {
if (["pagebreak", "title", "text"].includes(row.mode)) continue
const values = [row.quantity, row.price, row.discountPercent, row.taxPercent].map(value => Number(value ?? 0))
if (!values.every(Number.isFinite)) throw new Error(`Beleg ${document.id}: ungültiger Positionsbetrag`)
const [quantity, price, discount, tax] = values
const rowNet = Number((quantity * price * (1 - discount / 100)).toFixed(3))
net += rowNet
if (tax === 19) tax19 += rowNet * 0.19
if (tax === 7) tax7 += rowNet * 0.07
}
for (const id of document.usedAdvanceInvoices || []) {
const row = advanceInvoices.find(invoice => invoice.id === id)?.rows?.find(row => row.advanceInvoiceData)
if (row) advanceAmount += Number(row.price) * ((100 + Number(row.taxPercent)) / 100)
}
// Match the outgoing-document screen (useSum): round net, 19% and 7% separately.
// Aggregating both tax buckets before rounding can falsely report a one-cent debt.
return money(money(net) + money(tax19) + money(tax7) - advanceAmount)
}
export function outgoingDocumentStatus(document: any, paidAmount: number, cancellations: any[], advanceInvoices: any[]) {
const amount = documentAmount(document, advanceInvoices)
if (![amount, paidAmount].every(Number.isFinite)) throw new Error(`Beleg ${document.id}: Betrag kann nicht berechnet werden`)
const openAmount = money(amount - paidAmount)
const cancelled = cancellations.length > 0
const applicable = document.state === "Gebucht" && ["invoices", "advanceInvoices"].includes(document.type) && !cancelled
// Signed invoices and refunds use the same payment-direction comparison.
const direction = Math.sign(amount) || 1
const paymentStatus = !applicable ? null : openAmount === 0 ? "Bezahlt"
: openAmount * direction < 0 ? "Überbezahlt" : paidAmount * direction > 0 ? "Teilbezahlt" : "Offen"
return {
id: document.id, type: document.type, documentNumber: document.documentNumber, documentDate: document.documentDate,
state: document.state, customer: document.customer, project: document.project,
cancelled, cancellationDocuments: cancellations.map(({ id, documentNumber }) => ({ id, documentNumber })),
paymentStatus, amount, paidAmount: money(paidAmount), openAmount,
}
}
async function statusesFor(context: McpContext, documents: Array<typeof createddocuments.$inferSelect>) {
if (!documents.length) return []
const ids = documents.map(document => document.id)
const advanceIds = [...new Set(documents.flatMap(document => Array.isArray(document.usedAdvanceInvoices)
? document.usedAdvanceInvoices.filter(id => typeof id === "number" && Number.isSafeInteger(id) && id > 0) as number[] : []))]
const [allocations, cancellations, advances] = await Promise.all([
context.server.db.select({ createddocument: statementallocations.createddocument, amount: statementallocations.amount }).from(statementallocations)
.where(and(eq(statementallocations.tenant, context.tenantId), eq(statementallocations.archived, false), inArray(statementallocations.createddocument, ids))),
context.server.db.select({ id: createddocuments.id, createddocument: createddocuments.createddocument, documentNumber: createddocuments.documentNumber }).from(createddocuments)
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), eq(createddocuments.type, "cancellationInvoices"),
ne(createddocuments.state, "Entwurf"), inArray(createddocuments.createddocument, ids))).orderBy(createddocuments.id),
advanceIds.length ? context.server.db.select().from(createddocuments)
.where(and(eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false), inArray(createddocuments.id, advanceIds))) : [],
])
return documents.map(document => outgoingDocumentStatus(document,
allocations.filter(allocation => allocation.createddocument === document.id).reduce((sum, allocation) => sum + Number(allocation.amount), 0),
cancellations.filter(cancellation => cancellation.createddocument === document.id), advances))
}
export const outgoingStatusTools: McpTool[] = [
{
name: "accounting.outgoing_documents.status.get", title: "Ausgangsbelegstatus laden",
description: "Lädt Buchungs-, Zahlungs- und Stornostatus mit Gesamtbetrag, zugeordneten Zahlungen und offenem Betrag. Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen; sonst null. Ändert keine Belege oder Buchungen.",
requiredPermissions: ["accounting.outgoing_documents.read"],
inputSchema: { ...objectField({ id: idField }).schema, required: ["id"] },
async handler(context, args) {
const { id } = objectField({ id: idField }).parse(args, "arguments")
if (id === undefined) throw new Error("id ist erforderlich")
const documents = await context.server.db.select().from(createddocuments)
.where(and(eq(createddocuments.id, id), eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false))).limit(1)
if (!documents.length) throw new Error("Ausgangsbeleg nicht gefunden oder archiviert")
const [status] = await statusesFor(context, documents)
return { status }
},
},
{
name: "accounting.outgoing_documents.status.list", title: "Ausgangsbelege nach Status filtern",
description: "Listet Status und Beträge aktiver Ausgangsbelege. Kombinierbare Filter: state (z. B. Entwurf/Gebucht), paymentStatus, cancelled, type, customer und project. Statusfilter werden vor der Pagination angewendet. Archivierte Zahlungen und Stornos zählen nicht.",
requiredPermissions: ["accounting.outgoing_documents.read"], inputSchema: objectField(listFields).schema,
async handler(context, args) {
const input = objectField(listFields).parse(args, "arguments")
const conditions = [eq(createddocuments.tenant, context.tenantId), eq(createddocuments.archived, false)]
for (const key of ["state", "type", "customer", "project"]) {
if (input[key] !== undefined) conditions.push(eq(createddocuments[key], input[key]))
}
const limit = limitFromArgs(input)
const offset = offsetFromArgs(input)
const query = (size: number, start: number) => context.server.db.select().from(createddocuments).where(and(...conditions))
.orderBy(desc(createddocuments.createdAt), createddocuments.id).limit(size).offset(start)
if (input.paymentStatus === undefined && input.cancelled === undefined) {
return paginatedResult(await statusesFor(context, await query(limit + 1, offset)), input)
}
// Derived statuses need related payments/credit documents. Scan bounded batches so filtering
// happens before pagination without loading an entire tenant's documents into memory.
const rows: Awaited<ReturnType<typeof statusesFor>> = []
let matched = 0
for (let start = 0; ; start += 100) {
const documents = await query(100, start)
for (const status of await statusesFor(context, documents)) {
if (input.paymentStatus !== undefined && status.paymentStatus !== input.paymentStatus) continue
if (input.cancelled !== undefined && status.cancelled !== input.cancelled) continue
if (matched++ >= offset) rows.push(status)
if (rows.length > limit) return paginatedResult(rows, input)
}
if (documents.length < 100) return paginatedResult(rows, input)
}
},
},
]

View File

@@ -23,6 +23,8 @@ export type McpTool = {
title: string title: string
description: string description: string
requiredPermissions: string[] requiredPermissions: string[]
/** Overrides the readOnlyHint derived from requiredPermissions, e.g. for per-entity checks. */
readOnly?: boolean
inputSchema: Record<string, unknown> inputSchema: Record<string, unknown>
handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown> handler: (context: McpContext, args: Record<string, unknown>) => Promise<unknown>
} }

View File

@@ -0,0 +1,71 @@
import { FastifyInstance } from "fastify"
import { and, eq, sql } from "drizzle-orm"
import { bankaccounts, bankstatements } from "../../db/schema"
export const CASHBOOK_BANK_ID = "fedeo-cashbook"
type BalanceAccount = Pick<typeof bankaccounts.$inferSelect, "id" | "name" | "iban" | "ownerName" | "bankId" | "datevNumber" | "balance" | "expired" | "syncedAt">
const roundMoney = (value: number) => Math.round(value * 100) / 100
/**
* Bank accounts store the last synced booked balance; cashbooks store their opening balance
* and derive the current balance from their bookings. For a past date, bank balances are
* reconstructed by reverting the statements booked after that date.
*/
export function calculateAccountBalance(account: BalanceAccount, statementSum: number, asOf: string | null) {
const cashbook = account.bankId === CASHBOOK_BANK_ID
const stored = Number(account.balance || 0)
const balance = cashbook ? stored + statementSum : stored - statementSum
return {
id: account.id,
type: cashbook ? "cashbook" : "bank",
name: account.name || account.ownerName || (cashbook ? "Kassenbuch" : "Bankkonto"),
iban: cashbook ? null : account.iban,
datevNumber: account.datevNumber,
expired: account.expired,
balance: roundMoney(balance),
currency: "EUR",
...(cashbook ? { openingBalance: roundMoney(stored) } : { syncedBalance: roundMoney(stored), syncedAt: account.syncedAt }),
source: cashbook ? "cashbook_bookings" : asOf ? "bank_sync_reconstructed" : "bank_sync",
}
}
export async function loadBankBalances(server: FastifyInstance, tenantId: number, options: { account?: number | null; asOf?: string | null } = {}) {
const asOf = options.asOf || null
const conditions = [eq(bankaccounts.tenant, tenantId), eq(bankaccounts.archived, false)]
if (options.account) conditions.push(eq(bankaccounts.id, options.account))
const accounts = await server.db.select().from(bankaccounts).where(and(...conditions)).orderBy(bankaccounts.name, bankaccounts.id)
const sums = async (dateCondition: any) => {
const rows = await server.db
.select({ account: bankstatements.account, total: sql<string>`coalesce(sum(${bankstatements.amount}), 0)` })
.from(bankstatements)
.where(and(eq(bankstatements.tenant, tenantId), eq(bankstatements.archived, false), ...(dateCondition ? [dateCondition] : [])))
.groupBy(bankstatements.account)
return new Map(rows.map((row) => [row.account, Number(row.total)]))
}
// Synced statements may carry a full timestamp; compare the calendar day only.
const bookingDay = sql`left(${bankstatements.date}, 10)`
// Cashbooks need bookings up to the date, bank accounts only those after it.
const cashbookSums = await sums(asOf ? sql`${bookingDay} <= ${asOf}` : null)
const bankSums = asOf ? await sums(sql`${bookingDay} > ${asOf}`) : new Map<number, number>()
const balances = accounts.map((account) => calculateAccountBalance(
account,
(account.bankId === CASHBOOK_BANK_ID ? cashbookSums : bankSums).get(account.id) || 0,
asOf,
))
const active = balances.filter((account) => !account.expired)
return {
asOf,
accounts: balances,
totals: {
balance: roundMoney(active.reduce((sum, account) => sum + account.balance, 0)),
bank: roundMoney(active.filter((account) => account.type === "bank").reduce((sum, account) => sum + account.balance, 0)),
cashbook: roundMoney(active.filter((account) => account.type === "cashbook").reduce((sum, account) => sum + account.balance, 0)),
currency: "EUR",
note: "Summen ohne abgelaufene Bankverbindungen.",
},
}
}

View File

@@ -57,10 +57,13 @@ const adminPermissions = [
"organisation.projects.write", "organisation.projects.write",
"organisation.plants.read", "organisation.plants.read",
"organisation.events.read", "organisation.events.read",
"organisation.events.write",
"organisation.tasks.read", "organisation.tasks.read",
"organisation.tasks.write", "organisation.tasks.write",
"wiki.read", "wiki.read",
"wiki.write", "wiki.write",
"documents.read",
"documents.write",
] ]
const defaultTaxTypes = [ const defaultTaxTypes = [

View File

@@ -207,6 +207,7 @@ export class FederationService {
if (updated) { if (updated) {
await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${updated.id}`) await db.execute(sql`DELETE FROM collaboration_remote_messages WHERE binding_id=${updated.id}`)
await db.execute(sql`UPDATE collaboration_outbox SET status='failed',last_error='Neue Teilnahme erforderlich' WHERE binding_id=${updated.id} AND status='pending'`) await db.execute(sql`UPDATE collaboration_outbox SET status='failed',last_error='Neue Teilnahme erforderlich' WHERE binding_id=${updated.id} AND status='pending'`)
await this.service.options.notifications?.remoteInvitation(db, principal, updated.id, invitationId, snapshot.name)
} }
}) })
return { received: true } return { received: true }
@@ -241,9 +242,11 @@ export class FederationService {
for (const b of bindings) { for (const b of bindings) {
if (b.status === "pending" && new Date(b.invitation.expiresAt).getTime() > Date.now()) result.invitations.push({ ...b.invitation, id: b.id, remote: true }) if (b.status === "pending" && new Date(b.invitation.expiresAt).getTime() > Date.now()) result.invitations.push({ ...b.invitation, id: b.id, remote: true })
if (b.status === "active") { if (b.status === "active") {
const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`) const [unread] = await rows(this.service.db, sql`SELECT count(*)::int AS count,
(SELECT payload->>'createdAt' FROM collaboration_remote_messages WHERE binding_id=${b.id} ORDER BY seq DESC LIMIT 1) AS "lastActivityAt"
FROM collaboration_remote_messages WHERE binding_id=${b.id} AND seq>${b.last_read_seq} AND (payload->>'own')='false'`)
result.rooms.push({ id: b.id, name: b.snapshot?.name || b.invitation.name, topic: b.snapshot?.topic || b.invitation.topic, result.rooms.push({ id: b.id, name: b.snapshot?.name || b.invitation.name, topic: b.snapshot?.topic || b.invitation.topic,
ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count }) ownerName: b.invitation.ownerName, status: b.snapshot?.status || 'active', remote: true, unread: unread.count, lastActivityAt: unread.lastActivityAt || null })
} }
} }
return result return result
@@ -322,6 +325,11 @@ export class FederationService {
for (const message of projected.filter(m => m.seq >= from)) await db.execute(sql`INSERT INTO collaboration_remote_messages(binding_id,id,seq,payload) for (const message of projected.filter(m => m.seq >= from)) await db.execute(sql`INSERT INTO collaboration_remote_messages(binding_id,id,seq,payload)
VALUES (${binding.id},${message.id},${message.seq},${JSON.stringify(message)}::jsonb) VALUES (${binding.id},${message.id},${message.seq},${JSON.stringify(message)}::jsonb)
ON CONFLICT(binding_id,id) DO UPDATE SET payload=excluded.payload`) ON CONFLICT(binding_id,id) DO UPDATE SET payload=excluded.payload`)
if (snapshot) {
const unreadAfter = Math.max(Number(current.sync_seq), Number(current.last_read_seq), snapshot.lastReadSeq)
const incoming = projected.filter(m => !m.own && m.seq >= from && m.seq > unreadAfter)
await this.service.options.notifications?.remoteMessages(db, current, snapshot.name, incoming)
}
}) })
} }
async memberRequest(ctx: UserContext, id: string, action: string, input: any = {}) { async memberRequest(ctx: UserContext, id: string, action: string, input: any = {}) {

View File

@@ -0,0 +1,139 @@
import type { FastifyInstance } from "fastify"
import { and, asc, eq, inArray, notInArray, sql } from "drizzle-orm"
import { authUsers, notificationsItems } from "../../../db/schema"
import { NotificationService } from "../notification.service"
import { CollaborationService, rows } from "./service"
import type { FederationService } from "./federation"
import type { Principal } from "./types"
const MESSAGE = "collaboration.message.new"
const INVITATION = "collaboration.room.invited"
/** Queue inside the source transaction; external delivery never holds up a chat command. */
export class CollaborationNotifications {
private working = false
private deferredUntil = new Map<string, number>()
constructor(readonly server: FastifyInstance, readonly service: CollaborationService, readonly federation: FederationService) {}
delivery(db: any) {
return new NotificationService({ db, log: this.server.log } as FastifyInstance, async (server, userId) => {
const [user] = await server.db.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).limit(1)
return user || null
})
}
async enqueue(db: any, principal: Principal, name: string, payload: Record<string, unknown>, message?: { body: string; senderName: string }) {
if (principal.kind !== "user" || principal.disabled) return
try { await this.service.assertPrincipal(principal, db) }
catch (error: any) { if ([403, 404].includes(error.statusCode)) return; throw error }
await this.delivery(db).enqueue({
tenantId: Number(principal.tenant_id), userId: principal.user_id!,
eventType: message ? MESSAGE : INVITATION,
title: message ? `Neue Nachricht in ${name}` : "Einladung zur Zusammenarbeit",
message: message ? `${message.senderName}: ${message.body || "Bild gesendet"}` : `Du wurdest in den Chat „${name}“ eingeladen.`,
channels: ["inapp", "email", "push"],
payload: { ...payload, principalId: principal.id,
link: message ? `/communication/chat?room=${encodeURIComponent(`collaboration:${payload.bindingId || payload.roomId}`)}` : "/communication/chat" },
})
}
async localMessage(db: any, room: any, sender: Principal, message: any) {
const recipients = await rows(db, sql`SELECT p.* FROM collaboration_members m JOIN collaboration_principals p ON p.id=m.principal_id
WHERE m.room_id=${room.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())
AND m.history_from_seq<=${message.seq} AND m.last_read_seq<${message.seq}
AND p.kind='user' AND NOT p.disabled AND p.id<>${sender.id}`)
for (const principal of recipients) await this.enqueue(db, principal, room.name,
{ roomId: room.id, messageId: message.id, seq: message.seq }, { body: message.body, senderName: sender.display_name })
}
async localInvitation(db: any, room: any, target: Principal, invitationId: string) {
await this.enqueue(db, target, room.name, { roomId: room.id, invitationId })
}
async remoteInvitation(db: any, principal: Principal, bindingId: string, invitationId: string, name: string) {
await this.enqueue(db, principal, name, { bindingId, invitationId })
}
async remoteMessages(db: any, binding: any, name: string, messages: any[]) {
if (!messages.length) return
const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${binding.principal_id}`)
if (!principal) return
for (const message of new Map(messages.map(message => [message.id, message])).values()) await this.enqueue(db, principal, name,
{ bindingId: binding.id, invitationId: binding.invitation_id, messageId: message.id, seq: message.seq }, message)
}
async eligible(db: any, item: typeof notificationsItems.$inferSelect): Promise<boolean | "defer"> {
const payload = item.payload as any
const [principal] = await rows(db, sql`SELECT * FROM collaboration_principals WHERE id=${payload.principalId}`)
if (!principal || principal.kind !== "user" || principal.user_id !== item.userId || Number(principal.tenant_id) !== item.tenantId) return false
try {
await this.service.assertPrincipal(principal, db)
if (payload.bindingId) {
if (!this.federation.options.enabled) return "defer"
const ctx = { principal, userId: item.userId, tenantId: item.tenantId, manage: false }
const binding = await this.federation.binding(ctx, payload.bindingId)
if (!binding || binding.invitation_id !== payload.invitationId) return false
await this.federation.allowed(item.tenantId, binding.origin, db)
if (item.eventType === INVITATION) return binding.status === "pending" && new Date(binding.invitation.expiresAt).getTime() > Date.now()
if (binding.status !== "active") return false
// Do not send cached content while current access cannot be confirmed by its owner.
const detail = await this.federation.sync(ctx, binding)
if (detail.offline) return "defer"
const current = await this.federation.binding(ctx, binding.id)
return current?.status === "active" && current.invitation_id === payload.invitationId
&& payload.seq >= detail.historyFromSeq && (item.channel === "inapp" || payload.seq > Number(current.last_read_seq))
}
const room = await this.service.roomRow(db, payload.roomId)
if (item.eventType === INVITATION) {
this.service.assertOpen(room, true)
const [invite] = await rows(db, sql`SELECT id FROM collaboration_invitations
WHERE id=${payload.invitationId} AND room_id=${room.id} AND target_principal_id=${principal.id}
AND status='pending' AND expires_at>now() AND (access_expires_at IS NULL OR access_expires_at>now())`)
return Boolean(invite)
}
const member = await this.service.membership(db, room, principal)
return payload.seq >= Number(member.history_from_seq) && (item.channel === "inapp" || payload.seq > Number(member.last_read_seq))
} catch (error: any) {
if ([401, 403, 404, 409, 410].includes(error.statusCode)) return false
throw error
}
}
async tick() {
if (this.working) return
this.working = true
try {
for (const [id, until] of this.deferredUntil) if (until <= Date.now()) this.deferredUntil.delete(id)
const pending = await this.server.db.select({ id: notificationsItems.id }).from(notificationsItems)
.where(and(eq(notificationsItems.status, "queued"), inArray(notificationsItems.eventType, [MESSAGE, INVITATION]),
this.deferredUntil.size ? notInArray(notificationsItems.id, [...this.deferredUntil.keys()]) : undefined))
.orderBy(asc(notificationsItems.createdAt)).limit(20)
for (const { id } of pending) {
try {
await this.server.db.transaction(async db => {
const [item] = await db.select().from(notificationsItems)
.where(and(eq(notificationsItems.id, id), eq(notificationsItems.status, "queued")))
.for("update", { skipLocked: true })
if (!item) return
const eligible = await this.eligible(db, item)
if (eligible === "defer") {
this.deferredUntil.set(id, Date.now() + 30000)
return
}
const delivery = this.delivery(db)
const enabled = item.channel === "inapp" || (await delivery.preferences(item.tenantId, item.userId)).events
.some(event => event.eventKey === item.eventType && event.channels[item.channel] === true)
if (!eligible || !enabled) {
await db.delete(notificationsItems).where(eq(notificationsItems.id, item.id))
return
}
await delivery.deliver(item)
})
} catch (error) {
this.deferredUntil.set(id, Date.now() + 30000)
this.server.log.error({ err: error, notificationId: id }, "Collaboration notification failed")
}
}
} finally { this.working = false }
}
}

View File

@@ -5,6 +5,7 @@ import { CollaborationService } from "./service"
import { FederationService } from "./federation" import { FederationService } from "./federation"
import { encrypt, decrypt } from "../../utils/crypt" import { encrypt, decrypt } from "../../utils/crypt"
import { collaborationConfig } from "./config" import { collaborationConfig } from "./config"
import { CollaborationNotifications } from "./notifications"
export function collaborationRuntime(server: FastifyInstance) { export function collaborationRuntime(server: FastifyInstance) {
const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({ const { apiUrl, publicUrl, enabled, disabledReason } = collaborationConfig({
@@ -16,12 +17,17 @@ export function collaborationRuntime(server: FastifyInstance) {
sendMail, sendMail,
}) })
const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt }) const federation = new FederationService(service, { apiUrl, enabled, disabledReason, seal: encrypt, unseal: decrypt })
const notifications = new CollaborationNotifications(server, service, federation)
service.options.notifications = notifications
service.options.changed = (roomId, db) => federation.changed(roomId, db) service.options.changed = (roomId, db) => federation.changed(roomId, db)
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db) service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
let timer: ReturnType<typeof setInterval> let timer: ReturnType<typeof setInterval>
server.addHook("onReady", async () => { server.addHook("onReady", async () => {
if (federation.options.enabled) await federation.keys() if (federation.options.enabled) await federation.keys()
timer = setInterval(() => federation.tick().catch(error => server.log.error({ err: error }, "Collaboration worker failed")), 5000) timer = setInterval(() => {
void federation.tick().catch(error => server.log.error({ err: error }, "Collaboration worker failed"))
void notifications.tick().catch(error => server.log.error({ err: error }, "Collaboration notification worker failed"))
}, 5000)
timer.unref() timer.unref()
}) })
server.addHook("onClose", async () => { clearInterval(timer) }) server.addHook("onClose", async () => { clearInterval(timer) })

View File

@@ -4,6 +4,7 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres"
import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types" import { email, fail, futureDate, hash, overview, Principal, sequence, text, UserContext, uuid } from "./types"
import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content" import { chatContent, chatImage, chatReactions, imageMetadata, setChatReaction } from "../chat-content"
import type { CollaborationNotifications } from "./notifications"
type Db = Pick<NodePgDatabase<any>, "execute" | "transaction"> type Db = Pick<NodePgDatabase<any>, "execute" | "transaction">
export const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (await db.execute(query)).rows export const rows = async (db: Pick<Db, "execute">, query: any): Promise<any[]> => (await db.execute(query)).rows
@@ -13,6 +14,7 @@ export type CollaborationOptions = {
sendMail: (to: string, subject: string, html: string) => Promise<{ success: boolean }>; sendMail: (to: string, subject: string, html: string) => Promise<{ success: boolean }>;
changed?: (roomId: string, db: any) => Promise<void>; changed?: (roomId: string, db: any) => Promise<void>;
invited?: (ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any) => Promise<void>; invited?: (ctx: UserContext, roomId: string, target: Principal, invitationId: string, db: any) => Promise<void>;
notifications?: CollaborationNotifications;
} }
/** Owner-side authority. All membership changes and sends serialize on the room row. */ /** Owner-side authority. All membership changes and sends serialize on the room row. */
@@ -142,6 +144,8 @@ export class CollaborationService {
t.name AS "ownerName", m.overview_allowed AS "overviewAllowed", t.name AS "ownerName", m.overview_allowed AS "overviewAllowed",
(SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id (SELECT count(*)::int FROM collaboration_messages cm WHERE cm.room_id=r.id
AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread AND cm.seq>=m.history_from_seq AND cm.seq>m.last_read_seq AND cm.principal_id<>${principal.id}) AS unread
,coalesce((SELECT max(cm.created_at) FROM collaboration_messages cm WHERE cm.room_id=r.id
AND cm.seq>=m.history_from_seq), r.created_at) AS "lastActivityAt"
FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id FROM collaboration_members m JOIN collaboration_rooms r ON r.id=m.room_id JOIN tenants t ON t.id=r.tenant_id
LEFT JOIN projects p ON p.id=r.project_id LEFT JOIN projects p ON p.id=r.project_id
WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now()) WHERE m.principal_id=${principal.id} AND m.status='active' AND (m.expires_at IS NULL OR m.expires_at>now())
@@ -201,6 +205,7 @@ export class CollaborationService {
const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body,image) const [message] = await rows(db, sql`INSERT INTO collaboration_messages(room_id,seq,principal_id,client_id,body,image)
VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body},${image ? JSON.stringify(image) : null}::jsonb) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`) VALUES (${room.id},${next.sequence},${principal.id},${clientId},${body},${image ? JSON.stringify(image) : null}::jsonb) RETURNING id,client_id AS "clientId",seq::float8 AS seq,body,created_at AS "createdAt"`)
await this.options.changed?.(room.id, db) await this.options.changed?.(room.id, db)
await this.options.notifications?.localMessage(db, room, principal, message)
return { ...message, image: imageMetadata(image), own: true, senderName: principal.display_name, senderKind: principal.kind } return { ...message, image: imageMetadata(image), own: true, senderName: principal.display_name, senderKind: principal.kind }
}) })
} }
@@ -302,6 +307,7 @@ export class CollaborationService {
if (!this.options.invited) fail("Föderation ist nicht konfiguriert", 503) if (!this.options.invited) fail("Föderation ist nicht konfiguriert", 503)
await this.options.invited(ctx, room.id, target, invite.id, db) await this.options.invited(ctx, room.id, target, invite.id, db)
} }
await this.options.notifications?.localInvitation(db, room, target, invite.id)
return invite return invite
}) })
return { ...result, token } return { ...result, token }

View File

@@ -17,7 +17,7 @@ import {
incominginvoices, incominginvoices,
} from "../../../db/schema" } from "../../../db/schema"
import { eq, and, isNull, not } from "drizzle-orm" import { eq, and, inArray, isNull, not } from "drizzle-orm"
const formatInvoiceItemDescription = (item: any) => { const formatInvoiceItemDescription = (item: any) => {
const parts = [ const parts = [
@@ -86,8 +86,25 @@ const mapGptDataToIncomingInvoice = (data: any, tenantId: number) => {
return itemInfo return itemInfo
} }
export type IncomingInvoicePreparationResult = {
filesFound: number
created: Array<{ fileId: string, invoiceId: number, source: string | null }>
skipped: Array<{ fileId: string, reason: string }>
error?: string
}
export type IncomingInvoicePreparationStatus = {
running: boolean
startedAt: string | null
finishedAt: string | null
result: IncomingInvoicePreparationResult | null
}
class FileAlreadyPreparedError extends Error {}
export function prepareIncomingInvoices(server: FastifyInstance) { export function prepareIncomingInvoices(server: FastifyInstance) {
const processInvoices = async (tenantId:number) => { const processInvoices = async (tenantId:number, fileIds?: string[]) => {
const result: IncomingInvoicePreparationResult = { filesFound: 0, created: [], skipped: [] }
console.log("▶ Starting Incoming Invoice Preparation") console.log("▶ Starting Incoming Invoice Preparation")
const tenantsRes = await server.db const tenantsRes = await server.db
@@ -98,7 +115,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
if (!tenantsRes.length) { if (!tenantsRes.length) {
console.log("No tenants with autoPrepareIncomingInvoices = true") console.log("No tenants with autoPrepareIncomingInvoices = true")
return return { ...result, error: "Mandant nicht gefunden" }
} }
console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`) console.log(`Processing tenants: ${tenantsRes.map(t => t.id).join(", ")}`)
@@ -124,6 +141,7 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
const invoiceFileTag = tagRes?.[0]?.id const invoiceFileTag = tagRes?.[0]?.id
if (!invoiceFileTag) { if (!invoiceFileTag) {
server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`) server.log.error(`❌ Missing filetag 'invoices' for tenant ${tenantId}`)
result.error = "Dateityp für Eingangsrechnungen fehlt"
continue continue
} }
@@ -137,10 +155,13 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
eq(files.type, invoiceFileTag), eq(files.type, invoiceFileTag),
isNull(files.incominginvoice), isNull(files.incominginvoice),
eq(files.archived, false), eq(files.archived, false),
not(isNull(files.path)) not(isNull(files.path)),
...(fileIds ? [inArray(files.id, fileIds)] : [])
) )
) )
result.filesFound += filesRes.length
if (!filesRes.length) { if (!filesRes.length) {
console.log(`No invoice files for tenant ${tenantId}`) console.log(`No invoice files for tenant ${tenantId}`)
continue continue
@@ -150,95 +171,152 @@ export function prepareIncomingInvoices(server: FastifyInstance) {
// 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden // 3️⃣ Strukturierte E-Rechnung bevorzugen, GPT als PDF-Fallback verwenden
// ------------------------------------------------------------- // -------------------------------------------------------------
for (const file of filesRes) { for (const file of filesRes) {
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
let fileData: Buffer
try { try {
fileData = await loadFileBuffer(file.path!) await processFile(tenantId, file, result)
} catch (error) { } catch (error) {
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`) if (error instanceof FileAlreadyPreparedError) {
continue result.skipped.push({ fileId: file.id, reason: "Datei wurde bereits vorbereitet" })
}
const electronicInvoice = await detectElectronicInvoice(fileData, file)
let itemInfo: any = null
if (electronicInvoice) {
server.log.info({
fileId: file.id,
container: electronicInvoice.container,
syntax: electronicInvoice.syntax,
attachmentName: electronicInvoice.attachmentName,
}, "Strukturierte E-Rechnung erkannt.")
try {
const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
itemInfo = mapElectronicInvoiceToIncomingInvoice(
parsedInvoice,
tenantId,
vendor?.id || null,
electronicInvoice.container,
electronicInvoice.attachmentName,
)
} catch (error) {
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
if (electronicInvoice.container === "xml") continue
}
}
if (!itemInfo) {
const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`)
continue continue
} }
server.log.error(error, `Datei ${file.id} konnte nicht vorbereitet werden.`)
itemInfo = mapGptDataToIncomingInvoice(data, tenantId) result.skipped.push({ fileId: file.id, reason: "Eingangsbeleg konnte nicht angelegt werden" })
} }
}
}
// --------------------------------------------------------- return result
// 4️⃣ IncomingInvoice erstellen }
// ---------------------------------------------------------
const inserted = await server.db
.insert(incominginvoices)
.values(itemInfo)
.returning()
const newInvoice = inserted?.[0] const processFile = async (tenantId: number, file: typeof files.$inferSelect, result: IncomingInvoicePreparationResult) => {
console.log(`Processing file ${file.id} for tenant ${tenantId}`)
if (!newInvoice) { let fileData: Buffer
server.log.error(`Failed to insert incoming invoice for file ${file.id}`)
continue try {
fileData = await loadFileBuffer(file.path!)
} catch (error) {
server.log.error(error, `Datei ${file.id} konnte nicht aus S3 geladen werden.`)
result.skipped.push({ fileId: file.id, reason: "Datei konnte nicht geladen werden" })
return
}
const electronicInvoice = await detectElectronicInvoice(fileData, file)
let itemInfo: any = null
if (electronicInvoice) {
server.log.info({
fileId: file.id,
container: electronicInvoice.container,
syntax: electronicInvoice.syntax,
attachmentName: electronicInvoice.attachmentName,
}, "Strukturierte E-Rechnung erkannt.")
try {
const parsedInvoice = parseElectronicInvoice(electronicInvoice.xml, electronicInvoice.syntax)
const vendor = await findElectronicInvoiceVendor(server, tenantId, parsedInvoice)
itemInfo = mapElectronicInvoiceToIncomingInvoice(
parsedInvoice,
tenantId,
vendor?.id || null,
electronicInvoice.container,
electronicInvoice.attachmentName,
)
} catch (error) {
server.log.error(error, `E-Rechnung aus Datei ${file.id} konnte nicht verarbeitet werden.`)
if (electronicInvoice.container === "xml") {
result.skipped.push({ fileId: file.id, reason: "E-Rechnung konnte nicht verarbeitet werden" })
return
} }
}
}
// --------------------------------------------------------- if (!itemInfo) {
// 5️⃣ Datei mit incominginvoice-ID verbinden const data = await getInvoiceDataFromGPT(server, file, tenantId, fileData)
// ---------------------------------------------------------
await server.db
.update(files)
.set({ incominginvoice: newInvoice.id })
.where(eq(files.id, file.id))
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`) if (!data) {
server.log.warn(`GPT returned no data for file ${file.id}`)
result.skipped.push({ fileId: file.id, reason: "Keine Belegdaten erkannt" })
return
} }
itemInfo = mapGptDataToIncomingInvoice(data, tenantId)
} }
// ---------------------------------------------------------
// 4️⃣ IncomingInvoice anlegen und Datei atomar verknüpfen.
// Die Verknüpfung greift nur, solange die Datei noch frei ist;
// sonst wird der Beleg verworfen, damit keine Dubletten entstehen.
// ---------------------------------------------------------
const newInvoice = await server.db.transaction(async (tx) => {
const [invoice] = await tx
.insert(incominginvoices)
.values(itemInfo)
.returning()
return if (!invoice) throw new Error("Eingangsbeleg konnte nicht angelegt werden")
const linked = await tx
.update(files)
.set({ incominginvoice: invoice.id })
.where(and(eq(files.id, file.id), isNull(files.incominginvoice)))
.returning({ id: files.id })
if (!linked.length) throw new FileAlreadyPreparedError()
return invoice
})
result.created.push({ fileId: file.id, invoiceId: newInvoice.id, source: newInvoice.preparationSource ?? null })
console.log(`IncomingInvoice ${newInvoice.id} created for file ${file.id}`)
} }
return { // Serialize runs per tenant so concurrent triggers cannot prepare the same file twice.
run: async (tenant:number) => { const running = new Map<number, Promise<unknown>>()
await processInvoices(tenant) const backgroundRuns = new Map<number, IncomingInvoicePreparationStatus>()
const run = async (tenant:number, options: { fileIds?: string[] } = {}) => {
const previous = running.get(tenant) || Promise.resolve()
const current = previous.catch(() => undefined).then(() => processInvoices(tenant, options.fileIds))
running.set(tenant, current)
try {
const result = await current
console.log("Incoming Invoice Preparation Completed.") console.log("Incoming Invoice Preparation Completed.")
return result
} finally {
if (running.get(tenant) === current) running.delete(tenant)
} }
} }
const status = (tenant: number): IncomingInvoicePreparationStatus =>
backgroundRuns.get(tenant) || { running: false, startedAt: null, finishedAt: null, result: null }
// Startet die Vorbereitung losgelöst von der Anfrage; ein laufender Vorgang wird nicht erneut gestartet.
const start = (tenant: number): IncomingInvoicePreparationStatus => {
const current = status(tenant)
if (current.running) return current
const next: IncomingInvoicePreparationStatus = { running: true, startedAt: new Date().toISOString(), finishedAt: null, result: null }
backgroundRuns.set(tenant, next)
run(tenant)
.then((result) => {
backgroundRuns.set(tenant, { ...next, running: false, finishedAt: new Date().toISOString(), result })
})
.catch((error) => {
server.log.error(error, `Belegvorbereitung für Mandant ${tenant} fehlgeschlagen.`)
backgroundRuns.set(tenant, {
...next,
running: false,
finishedAt: new Date().toISOString(),
result: { filesFound: 0, created: [], skipped: [], error: "Belegvorbereitung fehlgeschlagen" },
})
})
return next
}
return { run, start, status }
} }

View File

@@ -0,0 +1,126 @@
import { and, eq, inArray } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { costcentres, incominginvoices } from "../../db/schema"
import { insertHistoryItem } from "../utils/history"
export type AccountCostCentreAssignment = {
index: number
costcentre: string | null
}
export type IncomingInvoiceCostCentreUpdate = {
accountCostCentres?: AccountCostCentreAssignment[]
}
const serviceError = (message: string, statusCode = 400) =>
Object.assign(new Error(message), { statusCode })
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
export function normalizeAccountCostCentreAssignments(input: unknown): AccountCostCentreAssignment[] {
if (!Array.isArray(input)) throw serviceError("accountCostCentres muss ein Array sein")
if (!input.length) throw serviceError("Mindestens eine Positionskostenstelle ist erforderlich")
const seen = new Set<number>()
return input.map((assignment) => {
const index = assignment?.index
const costcentre = assignment?.costcentre
if (!Number.isInteger(index) || index < 0) throw serviceError("Jede Positionszuordnung benötigt einen gültigen index")
if (seen.has(index)) throw serviceError(`Position ${index + 1} ist mehrfach angegeben`)
seen.add(index)
if (costcentre !== null && (typeof costcentre !== "string" || !UUID_PATTERN.test(costcentre))) {
throw serviceError(`Ungültige Kostenstelle für Position ${index + 1}`)
}
return { index, costcentre }
})
}
export function applyAccountCostCentreAssignments(
accounts: unknown,
assignments: AccountCostCentreAssignment[] = [],
) {
if (!Array.isArray(accounts)) throw serviceError("Der Eingangsbeleg enthält keine Positionen")
for (const { index } of assignments) {
const account = accounts[index]
if (!account || typeof account !== "object" || Array.isArray(account)) {
throw serviceError(`Position ${index + 1} wurde im Eingangsbeleg nicht gefunden`)
}
}
const assignmentMap = new Map(assignments.map((assignment) => [assignment.index, assignment.costcentre]))
return accounts.map((account, index) => {
if (!assignmentMap.has(index)) return account
const { costcentre: _legacyCostCentre, ...rest } = account as Record<string, unknown>
return { ...rest, costCentre: assignmentMap.get(index) ?? null }
})
}
export async function updateIncomingInvoiceCostCentres(
server: FastifyInstance,
tenantId: number,
userId: string,
invoiceId: number,
input: IncomingInvoiceCostCentreUpdate,
) {
const assignments = normalizeAccountCostCentreAssignments(input?.accountCostCentres)
const [existing] = await server.db
.select()
.from(incominginvoices)
.where(and(
eq(incominginvoices.id, invoiceId),
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.archived, false),
))
.limit(1)
if (!existing) throw serviceError("Eingangsbeleg nicht gefunden", 404)
const referencedCostCentreIds = [...new Set(
assignments.map((assignment) => assignment.costcentre).filter((id): id is string => Boolean(id)),
)]
if (referencedCostCentreIds.length) {
const validCostCentres = await server.db
.select({ id: costcentres.id })
.from(costcentres)
.where(and(
eq(costcentres.tenant, tenantId),
eq(costcentres.archived, false),
inArray(costcentres.id, referencedCostCentreIds),
))
if (validCostCentres.length !== referencedCostCentreIds.length) {
throw serviceError("Mindestens eine Kostenstelle wurde nicht gefunden oder ist archiviert")
}
}
const [updated] = await server.db
.update(incominginvoices)
.set({
accounts: applyAccountCostCentreAssignments(existing.accounts, assignments),
updatedAt: new Date(),
updatedBy: userId,
})
.where(and(
eq(incominginvoices.id, invoiceId),
eq(incominginvoices.tenant, tenantId),
eq(incominginvoices.archived, false),
))
.returning()
if (!updated) throw serviceError("Eingangsbeleg nicht gefunden", 404)
await insertHistoryItem(server, {
tenant_id: tenantId,
created_by: userId,
entity: "incominginvoices",
entityId: invoiceId,
action: "updated",
oldVal: existing,
newVal: updated,
text: "Kostenstellen des Eingangsbelegs geändert",
})
return updated
}

View File

@@ -56,6 +56,24 @@ export class NotificationService {
) {} ) {}
async trigger(input: TriggerInput) { async trigger(input: TriggerInput) {
const { items, skipped } = await this.createItems(input)
if (skipped !== undefined) return { success: true, created: 0, delivered: 0, skipped }
const results = []
for (const item of items) results.push(await this.deliver(item))
return {
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
}
/** Persist first; callers using a transaction can deliver after it commits. */
async enqueue(input: TriggerInput) {
return (await this.createItems(input)).items
}
private async createItems(input: TriggerInput): Promise<{ items: (typeof notificationsItems.$inferSelect)[]; skipped?: number }> {
const tenantId = input.tenantId const tenantId = input.tenantId
const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[] const userIds = Array.from(new Set([...(input.userIds || []), input.userId].filter(Boolean))) as string[]
@@ -68,7 +86,7 @@ export class NotificationService {
const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel)) const channels = requestedChannels.filter((channel) => allowedChannels.includes(channel))
if (!channels.length) { if (!channels.length) {
return { success: true, created: 0, delivered: 0, skipped: userIds.length } return { items: [], skipped: userIds.length }
} }
const results = [] const results = []
@@ -99,16 +117,11 @@ export class NotificationService {
const item = itemRows[0] const item = itemRows[0]
if (!item) continue if (!item) continue
results.push(await this.deliver(item)) results.push(item)
} }
} }
return { return { items: results }
success: results.every((result) => result.success),
created: results.length,
delivered: results.filter((result) => result.success).length,
failed: results.filter((result) => !result.success).length,
}
} }
async preferences(tenantId: number, userId: string) { async preferences(tenantId: number, userId: string) {
@@ -295,11 +308,12 @@ export class NotificationService {
const defaultPref = defaults.find((pref) => pref.channel === channel) const defaultPref = defaults.find((pref) => pref.channel === channel)
if (defaultPref) return defaultPref.enabled if (defaultPref) return defaultPref.enabled
if (input.eventType === "collaboration.message.new") return channel === "inapp"
return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited" return channel !== "email" && input.eventType !== "communication.message.room" && input.eventType !== "communication.room.invited"
}) })
} }
private async deliver(item: typeof notificationsItems.$inferSelect) { async deliver(item: typeof notificationsItems.$inferSelect) {
if (item.channel === "inapp") { if (item.channel === "inapp") {
await this.markSent(item.id) await this.markSent(item.id)
return { success: true, id: item.id, channel: item.channel } return { success: true, id: item.id, channel: item.channel }

View File

@@ -36,13 +36,13 @@ export function assignmentId(kind: string, id: unknown) {
return Number(id) return Number(id)
} }
type Reader = Pick<FastifyInstance["db"], "select"> type Reader = Pick<FastifyInstance["db"], "select">
export async function assignmentOptions(db: Reader, tenantId: number, kind: string, search = "") { export async function assignmentOptions(db: Reader, tenantId: number, kind: string, search = "", limit = 100, offset = 0) {
const target = targetFor(kind) const target = targetFor(kind)
const name = sql<string>`coalesce(nullif(${target.name}, ''), '#' || ${target.id}::text)` const name = sql<string>`coalesce(nullif(${target.name}, ''), '#' || ${target.id}::text)`
const rows = await db.select({ id: target.id, name }).from(target.table) const rows = await db.select({ id: target.id, name }).from(target.table)
.where(and(eq(target.tenant, tenantId), target.available, .where(and(eq(target.tenant, tenantId), target.available,
search ? ilike(name, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined)) search ? ilike(name, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined))
.orderBy(asc(target.name), asc(target.id)).limit(100) .orderBy(asc(target.name), asc(target.id)).limit(limit).offset(offset)
return rows.map(row => ({ id: String(row.id), name: row.name })) return rows.map(row => ({ id: String(row.id), name: row.name }))
} }
export async function currentAssignments(db: Reader, tenantId: number, file: Record<string, unknown>) { export async function currentAssignments(db: Reader, tenantId: number, file: Record<string, unknown>) {

View File

@@ -264,32 +264,34 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
// Epson probes writability with an empty PUT. No file, receipt or reservation is stored. // Epson probes writability with an empty PUT. No file, receipt or reservation is stored.
}) })
} }
const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => { const storeDocument = async (tenantId: number, filename: string, body: Buffer,
authorize: (tx: Pick<FastifyInstance["db"], "select">) => Promise<void>, onlyIfNew = false, userId?: string) => {
const contentType = validateScannerFile(filename, body) const contentType = validateScannerFile(filename, body)
const checksum = digest(body) const checksum = digest(body)
let uploadedKey: string | undefined let uploadedKey: string | undefined
try { try {
return await db.transaction(async tx => { return await db.transaction(async tx => {
await assertWritable(tx, identity) await authorize(tx)
// Serialise retries across processes; a lost HTTP response must not duplicate a document. // Serialise retries across processes; a lost HTTP response must not duplicate a document.
await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`) await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${tenantId}:${filename}`}, 0))`)
const [existing] = await tx.select().from(scannerInboxDocuments).where(and( const [existing] = await tx.select().from(scannerInboxDocuments).where(and(
eq(scannerInboxDocuments.tenantId, identity.tenantId), eq(scannerInboxDocuments.remoteFilename, filename), eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.remoteFilename, filename),
)).limit(1) )).limit(1)
if (existing) { if (existing) {
if (onlyIfNew) throw scannerError(412, "Der Dateiname ist bereits vorhanden.") if (onlyIfNew) throw scannerError(412, "Der Dateiname ist bereits vorhanden.")
if (existing.checksum !== checksum) throw scannerError(409, "Der Dateiname ist bereits vergeben. Bitte einen eindeutigen Dateinamen verwenden.") if (existing.checksum !== checksum) throw scannerError(409, "Der Dateiname ist bereits vergeben. Bitte einen eindeutigen Dateinamen verwenden.")
return { created: false, checksum } return { id: existing.fileId, created: false, checksum }
} }
const id = randomUUID() const id = randomUUID()
const key = `${identity.tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}` const key = `${tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}`
uploadedKey = key uploadedKey = key
await storage.put(key, body, contentType) await storage.put(key, body, contentType)
// Bypass saveFile's text extraction: the inbox only stores the original document. // Bypass saveFile's text extraction: the inbox only stores the original document.
await tx.insert(files).values({ id, tenant: identity.tenantId, name: filename, path: key, mimeType: contentType, size: body.length }) await tx.insert(files).values({ id, tenant: tenantId, name: filename, path: key, mimeType: contentType, size: body.length,
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId: identity.tenantId, remoteFilename: filename, checksum }) ...(userId ? { createdBy: userId } : {}) })
return { created: true, checksum } await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId, remoteFilename: filename, checksum })
return { id, created: true, checksum }
}) })
} catch (error) { } catch (error) {
if (uploadedKey) { if (uploadedKey) {
@@ -299,8 +301,17 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
throw error throw error
} }
} }
const accept = (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) =>
storeDocument(identity.tenantId, filename, body, tx => assertWritable(tx, identity), onlyIfNew)
// Internal callers authorize the user before invoking this method; scanner credentials remain separate.
const upload = (tenantId: number, userId: string, filename: string, body: Buffer) =>
storeDocument(tenantId, filename, body, 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.")
}, false, userId)
return { connection, credentials, disable, authenticate, find, list, openCount, filingOptions, getSuggestion, queueSuggestion, processNextSuggestion, 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 } getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), archiveDocument, fileDocument, probe, accept, upload }
} }
export type ScannerInboxService = ReturnType<typeof createScannerInboxService> export type ScannerInboxService = ReturnType<typeof createScannerInboxService>

View File

@@ -1,7 +1,7 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import fp from "fastify-plugin"; import fp from "fastify-plugin";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
import { and, eq } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { authUsers, m2mApiKeys } from "../../db/schema"; import { authUsers, m2mApiKeys } from "../../db/schema";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
@@ -44,7 +44,8 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
.where(and( .where(and(
eq(m2mApiKeys.keyHash, keyHash), eq(m2mApiKeys.keyHash, keyHash),
eq(m2mApiKeys.active, true) eq(m2mApiKeys.active, true),
isNull(authUsers.archived_at)
)) ))
.limit(1) .limit(1)
@@ -70,7 +71,7 @@ export default fp(async (server: FastifyInstance, opts: { allowedPrefix?: string
const users = await server.db const users = await server.db
.select({ email: authUsers.email }) .select({ email: authUsers.email })
.from(authUsers) .from(authUsers)
.where(eq(authUsers.id, userId)) .where(and(eq(authUsers.id, userId), isNull(authUsers.archived_at)))
.limit(1) .limit(1)
if (!users[0]) { if (!users[0]) {

View File

@@ -12,7 +12,7 @@ import {
tenants, tenants,
} from "../../db/schema" } from "../../db/schema"
import { eq, and, inArray } from "drizzle-orm" import { eq, and, inArray, isNull } from "drizzle-orm"
export default fp(async (server: FastifyInstance) => { export default fp(async (server: FastifyInstance) => {
const hashApiKey = (apiKey: string) => const hashApiKey = (apiKey: string) =>
@@ -56,7 +56,8 @@ export default fp(async (server: FastifyInstance) => {
.innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId)) .innerJoin(authUsers, eq(authUsers.id, m2mApiKeys.userId))
.where(and( .where(and(
eq(m2mApiKeys.keyHash, keyHash), eq(m2mApiKeys.keyHash, keyHash),
eq(m2mApiKeys.active, true) eq(m2mApiKeys.active, true),
isNull(authUsers.archived_at)
)) ))
.limit(1) .limit(1)
@@ -141,22 +142,16 @@ export default fp(async (server: FastifyInstance) => {
return reply.code(401).send({ error: "Invalid token" }) return reply.code(401).send({ error: "Invalid token" })
} }
// Payload an Request hängen // Check current account state even when a JWT carries cached admin claims.
req.user = payload const [currentUser] = await server.db
.select({ is_admin: authUsers.is_admin })
if (typeof payload.is_admin === "boolean") { .from(authUsers)
req.user.is_admin = payload.is_admin .where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at)))
} else { .limit(1)
const [currentUser] = await server.db if (!currentUser) {
.select({ return reply.code(401).send({ error: "Invalid or expired token" })
is_admin: authUsers.is_admin,
})
.from(authUsers)
.where(eq(authUsers.id, payload.user_id))
.limit(1)
req.user.is_admin = Boolean(currentUser?.is_admin)
} }
req.user = { ...payload, is_admin: Boolean(currentUser.is_admin) }
// Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung // Multi-Tenant Modus ohne ausgewählten Tenant → keine Rollenprüfung
if (!req.user.tenant_id) { if (!req.user.tenant_id) {

View File

@@ -33,8 +33,12 @@ import { requireSystemAdmin } from "../utils/requireSystemAdmin";
import { buildSystemStatus } from "../modules/system-status.service"; import { buildSystemStatus } from "../modules/system-status.service";
import { s3 } from "../utils/s3"; import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
import adminUserArchiveRoutes from "./adminUserArchive";
import { getPortalLoginUrl } from "../utils/portalUrl";
export default async function adminRoutes(server: FastifyInstance) { export default async function adminRoutes(server: FastifyInstance) {
await adminUserArchiveRoutes(server);
await server.register(multipart, { await server.register(multipart, {
limits: { fileSize: 1024 * 1024 * 1024 } limits: { fileSize: 1024 * 1024 * 1024 }
}); });
@@ -601,6 +605,7 @@ export default async function adminRoutes(server: FastifyInstance) {
const currentUser = await requireAdmin(req, reply); const currentUser = await requireAdmin(req, reply);
if (!currentUser) return; if (!currentUser) return;
const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true";
const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([ const [tenantRows, userRows, profileRows, membershipRows, roleRows, roleAssignmentRows] = await Promise.all([
server.db server.db
.select({ .select({
@@ -620,8 +625,9 @@ export default async function adminRoutes(server: FastifyInstance) {
multiTenant: authUsers.multiTenant, multiTenant: authUsers.multiTenant,
must_change_password: authUsers.must_change_password, must_change_password: authUsers.must_change_password,
is_admin: authUsers.is_admin, is_admin: authUsers.is_admin,
archived_at: authUsers.archived_at,
}) })
.from(authUsers), .from(authUsers).where(includeArchived ? undefined : isNull(authUsers.archived_at)),
server.db server.db
.select({ .select({
id: authProfiles.id, id: authProfiles.id,
@@ -632,6 +638,7 @@ export default async function adminRoutes(server: FastifyInstance) {
full_name: authProfiles.full_name, full_name: authProfiles.full_name,
email: authProfiles.email, email: authProfiles.email,
active: authProfiles.active, active: authProfiles.active,
archived_at: authProfiles.archived_at,
}) })
.from(authProfiles), .from(authProfiles),
server.db server.db
@@ -680,16 +687,17 @@ export default async function adminRoutes(server: FastifyInstance) {
}; };
}); });
const activeUserIds = new Set(userRows.filter((user) => !user.archived_at).map((user) => user.id));
const tenantsWithCounts = tenantRows.map((tenant) => ({ const tenantsWithCounts = tenantRows.map((tenant) => ({
...tenant, ...tenant,
user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length, user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id && activeUserIds.has(membership.user_id)).length,
})); }));
return { return {
users, users,
tenants: tenantsWithCounts, tenants: tenantsWithCounts,
roles: roleRows, roles: roleRows,
unassignedProfiles: profileRows.filter((profile) => !profile.user_id), unassignedProfiles: profileRows.filter((profile) => !profile.user_id && !profile.archived_at && profile.active),
memberships: uniqueMembershipRows, memberships: uniqueMembershipRows,
roleAssignments: roleAssignmentRows, roleAssignments: roleAssignmentRows,
}; };
@@ -808,7 +816,7 @@ export default async function adminRoutes(server: FastifyInstance) {
email: authProfiles.email, email: authProfiles.email,
}) })
.from(authProfiles) .from(authProfiles)
.where(eq(authProfiles.id, profileId)) .where(and(eq(authProfiles.id, profileId), isNull(authProfiles.archived_at)))
.limit(1); .limit(1);
if (!profile) { if (!profile) {
@@ -931,6 +939,17 @@ export default async function adminRoutes(server: FastifyInstance) {
return reply.code(400).send({ error: "Customer has no email address" }); return reply.code(400).send({ error: "Customer has no email address" });
} }
let portalUrl: string;
try {
portalUrl = getPortalLoginUrl({
portalDomain: tenantRecord?.portalDomain,
publicUrl: process.env.FEDEO_PUBLIC_URL,
apiBaseUrl: secrets.API_BASE_URL,
});
} catch (err: any) {
return reply.code(503).send({ error: err.message });
}
const generatedPassword = generateRandomPassword(14); const generatedPassword = generateRandomPassword(14);
const passwordHash = await hashPassword(generatedPassword); const passwordHash = await hashPassword(generatedPassword);
@@ -939,11 +958,16 @@ export default async function adminRoutes(server: FastifyInstance) {
id: authUsers.id, id: authUsers.id,
email: authUsers.email, email: authUsers.email,
is_admin: authUsers.is_admin, is_admin: authUsers.is_admin,
archived_at: authUsers.archived_at,
}) })
.from(authUsers) .from(authUsers)
.where(eq(authUsers.email, email)) .where(eq(authUsers.email, email))
.limit(1); .limit(1);
if (existingUser?.archived_at) {
return reply.code(409).send({ error: "Dieses Benutzerkonto ist dauerhaft archiviert." });
}
const derivedName = deriveNameFromEmail(email); const derivedName = deriveNameFromEmail(email);
const firstName = customerRecord.firstname?.trim() || derivedName.first_name; const firstName = customerRecord.firstname?.trim() || derivedName.first_name;
const lastName = customerRecord.lastname?.trim() || derivedName.last_name; const lastName = customerRecord.lastname?.trim() || derivedName.last_name;
@@ -1086,8 +1110,6 @@ export default async function adminRoutes(server: FastifyInstance) {
}); });
} }
const portalUrl = tenantRecord?.portalDomain ? `https://${tenantRecord.portalDomain}/login` : null;
const mailResult = await sendMail( const mailResult = await sendMail(
email, email,
`FEDEO | Einladung ins Kundenportal`, `FEDEO | Einladung ins Kundenportal`,
@@ -1097,7 +1119,8 @@ export default async function adminRoutes(server: FastifyInstance) {
<p><strong>E-Mail:</strong> ${email}</p> <p><strong>E-Mail:</strong> ${email}</p>
<p><strong>Initialpasswort:</strong> ${generatedPassword}</p> <p><strong>Initialpasswort:</strong> ${generatedPassword}</p>
<p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p> <p>Bitte ändern Sie dieses Passwort direkt nach dem ersten Login.</p>
${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</a></p>` : ""} <p><a href="${portalUrl}">Zum Kundenportal anmelden</a></p>
<p>Ihre Login-Adresse: ${portalUrl}</p>
<p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p> <p>Viele Grüße<br>${tenantRecord?.name || "FEDEO"}</p>
` `
); );
@@ -1176,6 +1199,12 @@ export default async function adminRoutes(server: FastifyInstance) {
if (!currentUser) return; if (!currentUser) return;
const { user_id } = req.params as { user_id: string }; const { user_id } = req.params as { user_id: string };
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
if (!account) return reply.code(404).send({ error: "User not found" });
if (account.archived_at) {
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
}
const body = req.body as { const body = req.body as {
email?: string; email?: string;
multiTenant?: boolean; multiTenant?: boolean;
@@ -1225,6 +1254,12 @@ export default async function adminRoutes(server: FastifyInstance) {
if (!currentUser) return; if (!currentUser) return;
const { user_id } = req.params as { user_id: string }; const { user_id } = req.params as { user_id: string };
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
if (!account) return reply.code(404).send({ error: "User not found" });
if (account.archived_at) {
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
}
const body = (req.body || {}) as { password?: string }; const body = (req.body || {}) as { password?: string };
const requestedPassword = body.password?.trim(); const requestedPassword = body.password?.trim();
@@ -1625,6 +1660,12 @@ export default async function adminRoutes(server: FastifyInstance) {
if (!currentUser) return; if (!currentUser) return;
const { user_id } = req.params as { user_id: string }; const { user_id } = req.params as { user_id: string };
const [account] = await server.db.select({ archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1);
if (!account) return reply.code(404).send({ error: "User not found" });
if (account.archived_at) {
return reply.code(409).send({ error: "Archivierte Benutzer können nicht mehr bearbeitet werden." });
}
const body = req.body as { const body = req.body as {
tenant_ids?: number[]; tenant_ids?: number[];
role_assignments?: { tenant_id: number; role_id: string }[]; role_assignments?: { tenant_id: number; role_id: string }[];
@@ -1701,7 +1742,9 @@ export default async function adminRoutes(server: FastifyInstance) {
.where( .where(
and( and(
inArray(authProfiles.tenant_id, tenantIds), inArray(authProfiles.tenant_id, tenantIds),
isNull(authProfiles.user_id) isNull(authProfiles.user_id),
isNull(authProfiles.archived_at),
eq(authProfiles.active, true)
) )
) )
: []; : [];
@@ -1824,6 +1867,10 @@ export default async function adminRoutes(server: FastifyInstance) {
}); });
} }
const [targetUser] = await server.db.select({ id: authUsers.id })
.from(authUsers).where(and(eq(authUsers.id, body.user_id), isNull(authUsers.archived_at))).limit(1);
if (!targetUser) return reply.code(404).send({ error: "Aktiver Benutzer nicht gefunden." });
const mode = body.mode ?? "multi"; const mode = body.mode ?? "multi";
// ---------------------------- // ----------------------------

View File

@@ -0,0 +1,51 @@
import type { FastifyInstance } from "fastify"
import { eq } from "drizzle-orm"
import { authProfiles, authRefreshTokens, authUsers, m2mApiKeys } from "../../db/schema"
import { requireSystemAdmin } from "../utils/requireSystemAdmin"
export default async function adminUserArchiveRoutes(server: FastifyInstance) {
server.post("/admin/users/:user_id/archive", {
schema: {
params: {
type: "object",
required: ["user_id"],
properties: { user_id: { type: "string", format: "uuid" } },
},
},
}, async (req, reply) => {
const currentUser = await requireSystemAdmin(server, req, reply)
if (!currentUser) return
const { user_id } = req.params as { user_id: string }
if (user_id === currentUser.id) {
return reply.code(409).send({ error: "Das eigene Benutzerkonto kann nicht archiviert werden." })
}
const user = await server.db.transaction(async (tx) => {
// Serialize repeated archive requests and keep the original archive date.
const [target] = await tx.select({ id: authUsers.id, archived_at: authUsers.archived_at })
.from(authUsers).where(eq(authUsers.id, user_id)).limit(1).for("update")
if (!target) return null
const archivedAt = target.archived_at || new Date()
const [archivedUser] = await tx.update(authUsers)
.set({ archived_at: archivedAt, updatedAt: archivedAt })
.where(eq(authUsers.id, user_id))
.returning({ id: authUsers.id, archived_at: authUsers.archived_at })
// Keep links intact so existing documents and time records retain their authors.
await tx.update(authProfiles)
.set({ active: false, archived_at: archivedAt, token_id: null, calendar_subscription_token: null })
.where(eq(authProfiles.user_id, user_id))
await tx.update(authRefreshTokens).set({ revokedAt: archivedAt })
.where(eq(authRefreshTokens.userId, user_id))
await tx.update(m2mApiKeys).set({ active: false, updatedAt: archivedAt })
.where(eq(m2mApiKeys.userId, user_id))
return archivedUser
})
if (!user) return reply.code(404).send({ error: "Benutzer nicht gefunden." })
return { user }
})
}

View File

@@ -6,7 +6,8 @@ import { sendMail } from "../../utils/mailer";
import { authUsers } from "../../../db/schema"; import { authUsers } from "../../../db/schema";
import { authTenantUsers } from "../../../db/schema"; import { authTenantUsers } from "../../../db/schema";
import { tenants } from "../../../db/schema"; import { tenants } from "../../../db/schema";
import { and, eq } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { resolveLoginTenant } from "../../utils/loginTenant";
import { import {
createAccessToken, createAccessToken,
issueRefreshToken, issueRefreshToken,
@@ -111,7 +112,7 @@ export default async function authRoutes(server: FastifyInstance) {
const [found] = await server.db const [found] = await server.db
.select() .select()
.from(authUsers) .from(authUsers)
.where(eq(authUsers.email, body.email.toLowerCase())) .where(and(eq(authUsers.email, body.email.toLowerCase()), isNull(authUsers.archived_at)))
.limit(1); .limit(1);
if (!found) { if (!found) {
@@ -127,7 +128,7 @@ export default async function authRoutes(server: FastifyInstance) {
return reply.code(401).send({ error: "Invalid credentials" }); return reply.code(401).send({ error: "Invalid credentials" });
} }
const tenantId = req.tenant?.id ? Number(req.tenant.id) : null; const tenantId = await resolveLoginTenant(server, user.id, req.tenant?.id ? Number(req.tenant.id) : null);
const token = createAccessToken(user, tenantId); const token = createAccessToken(user, tenantId);
const refreshToken = await issueRefreshToken(server, user.id, tenantId); const refreshToken = await issueRefreshToken(server, user.id, tenantId);
@@ -215,7 +216,7 @@ export default async function authRoutes(server: FastifyInstance) {
mustChangePassword: authUsers.must_change_password, mustChangePassword: authUsers.must_change_password,
}) })
.from(authUsers) .from(authUsers)
.where(eq(authUsers.email, email.toLowerCase())) .where(and(eq(authUsers.email, email.toLowerCase()), isNull(authUsers.archived_at)))
.limit(1); .limit(1);
if (!user) { if (!user) {

View File

@@ -58,6 +58,7 @@ export default async function meRoutes(server: FastifyInstance) {
features: tenants.features, features: tenants.features,
extraModules: tenants.extraModules, extraModules: tenants.extraModules,
businessInfo: tenants.businessInfo, businessInfo: tenants.businessInfo,
portalConfig: tenants.portalConfig,
numberRanges: tenants.numberRanges, numberRanges: tenants.numberRanges,
accountChart: tenants.accountChart, accountChart: tenants.accountChart,
taxEvaluationPeriod: tenants.taxEvaluationPeriod, taxEvaluationPeriod: tenants.taxEvaluationPeriod,

View File

@@ -1,5 +1,5 @@
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { eq, and } from "drizzle-orm" import { eq, and, isNull } from "drizzle-orm"
import { import {
authUsers, authUsers,
@@ -83,7 +83,8 @@ export default async function userRoutes(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.user_id, id), eq(authProfiles.user_id, id),
eq(authProfiles.tenant_id, req.user.tenant_id) eq(authProfiles.tenant_id, req.user.tenant_id),
isNull(authProfiles.archived_at)
) )
) )
@@ -99,6 +100,7 @@ export default async function userRoutes(server: FastifyInstance) {
} }
const updateData: any = { ...data } const updateData: any = { ...data }
for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete updateData[key]
// bekannte Date-Felder prüfen // bekannte Date-Felder prüfen
if (data.entry_date !== undefined) if (data.entry_date !== undefined)
@@ -116,9 +118,10 @@ export default async function userRoutes(server: FastifyInstance) {
const [updatedProfile] = await server.db const [updatedProfile] = await server.db
.update(authProfiles) .update(authProfiles)
.set(updateData) .set(updateData)
.where(eq(authProfiles.id, profile.id)) .where(and(eq(authProfiles.id, profile.id), isNull(authProfiles.archived_at)))
.returning() .returning()
if (!updatedProfile) return reply.code(404).send({ error: "Profile not found in tenant" })
return { profile: updatedProfile } return { profile: updatedProfile }
} catch (err: any) { } catch (err: any) {

View File

@@ -10,6 +10,7 @@ import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics"
import { centralServicesClient } from "../modules/push-server.client" import { centralServicesClient } from "../modules/push-server.client"
import { findBankInstitutionByBic } from "../modules/banking-institution" import { findBankInstitutionByBic } from "../modules/banking-institution"
import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details" import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details"
import { CASHBOOK_BANK_ID } from "../modules/bank-balances.service"
import { import {
bankrequisitions, bankrequisitions,
@@ -33,9 +34,7 @@ import {
desc, desc,
} from "drizzle-orm" } from "drizzle-orm"
export default async function bankingRoutes(server: FastifyInstance) { export default async function bankingRoutes(server: FastifyInstance) {
const CASHBOOK_BANK_ID = "fedeo-cashbook"
const ContraAccounts = aliasedTable(accounts, "contra_accounts") const ContraAccounts = aliasedTable(accounts, "contra_accounts")
const ContraCustomers = aliasedTable(customers, "contra_customers") const ContraCustomers = aliasedTable(customers, "contra_customers")
const ContraVendors = aliasedTable(vendors, "contra_vendors") const ContraVendors = aliasedTable(vendors, "contra_vendors")

View File

@@ -1,6 +1,6 @@
import { createHash } from "node:crypto" import { createHash } from "node:crypto"
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { and, asc, desc, eq, gt, inArray, ne, sql } from "drizzle-orm" import { and, asc, desc, eq, gt, inArray, isNull, ne, sql } from "drizzle-orm"
import { import {
authProfiles, authProfiles,
authTenantUsers, authTenantUsers,
@@ -80,7 +80,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
eq(authProfiles.user_id, authTenantUsers.user_id), eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, tenantId) eq(authProfiles.tenant_id, tenantId)
)) ))
.where(eq(authTenantUsers.tenant_id, tenantId)) .where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at)))
const addMembers = async (roomId: string, userIds: string[]) => { const addMembers = async (roomId: string, userIds: string[]) => {
const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean))) const uniqueUserIds = Array.from(new Set(userIds.filter(Boolean)))
@@ -122,7 +122,8 @@ export default async function communicationRoutes(server: FastifyInstance) {
.where(eq(communicationRooms.id, room.id)).returning() .where(eq(communicationRooms.id, room.id)).returning()
} }
const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers) const users = await tx.select({ userId: authTenantUsers.user_id }).from(authTenantUsers)
.where(eq(authTenantUsers.tenant_id, tenantId)) .innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id))
.where(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at)))
if (users.length) await tx.insert(communicationRoomMembers) if (users.length) await tx.insert(communicationRoomMembers)
.values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId }))) .values([...new Set(users.map(user => user.userId))].map(userId => ({ roomId: room.id, userId })))
.onConflictDoNothing() .onConflictDoNothing()
@@ -157,6 +158,16 @@ export default async function communicationRoutes(server: FastifyInstance) {
entityUuid: room.entityUuid, entityUuid: room.entityUuid,
exists: true, exists: true,
}) })
// Rooms without messages fall back to their creation date so new rooms still sort by recency.
const withActivity = async <T extends { room: typeof communicationRooms.$inferSelect }>(rows: T[]) => {
const ids = rows.map(({ room }) => room.id)
const latest = ids.length ? await server.db.select({
roomId: communicationMessages.roomId,
at: sql<Date>`max(${communicationMessages.createdAt})`.mapWith(communicationMessages.createdAt),
}).from(communicationMessages).where(inArray(communicationMessages.roomId, ids)).groupBy(communicationMessages.roomId) : []
const byRoom = new Map(latest.map((row) => [row.roomId, row.at]))
return (room: typeof communicationRooms.$inferSelect) => (byRoom.get(room.id) || room.createdAt).toISOString()
}
const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => { const messagesForRoom = async (roomId: string, currentUserId: string, tenantId: number, afterId = 0, limit = 50) => {
const conditions = [eq(communicationMessages.roomId, roomId)] const conditions = [eq(communicationMessages.roomId, roomId)]
@@ -294,7 +305,8 @@ export default async function communicationRoutes(server: FastifyInstance) {
eq(communicationRooms.archived, false), eq(communicationRooms.archived, false),
eq(communicationRoomMembers.userId, req.user.user_id) eq(communicationRoomMembers.userId, req.user.user_id)
)).orderBy(asc(communicationRooms.name)) )).orderBy(asc(communicationRooms.name))
return { rooms: rows.map(({ room }) => roomToApi(room)) } const activity = await withActivity(rows)
return { rooms: rows.map(({ room }) => ({ ...roomToApi(room), lastActivityAt: activity(room) })) }
}) })
server.post("/communication/chat/rooms", async (req: any, reply) => { server.post("/communication/chat/rooms", async (req: any, reply) => {
@@ -334,9 +346,10 @@ export default async function communicationRoutes(server: FastifyInstance) {
projectChatAccess(server.db, req), projectChatAccess(server.db, req),
]) ])
const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room])) const roomsByProject = new Map(roomRows.map(({ room }) => [room.entityId, room]))
const activity = await withActivity(roomRows)
return { rooms: projectRows.map((project) => { return { rooms: projectRows.map((project) => {
const room = roomsByProject.get(project.id) const room = roomsByProject.get(project.id)
return room ? { ...roomToApi(room), projectId: project.id, projectNumber: project.projectNumber } : { return room ? { ...roomToApi(room), lastActivityAt: activity(room), projectId: project.id, projectNumber: project.projectNumber } : {
key: `project_${project.id}`, key: `project_${project.id}`,
name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name, name: project.projectNumber ? `${project.projectNumber} · ${project.name}` : project.name,
topic: `Projektkommunikation zu ${project.name}`, topic: `Projektkommunikation zu ${project.name}`,
@@ -372,7 +385,7 @@ export default async function communicationRoutes(server: FastifyInstance) {
const profileIds = (project.profiles || []) as string[] const profileIds = (project.profiles || []) as string[]
const memberProfiles = profileIds.length const memberProfiles = profileIds.length
? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles) ? await server.db.select({ userId: authProfiles.user_id }).from(authProfiles)
.where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds))) .where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds), eq(authProfiles.active, true)))
: [] : []
const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[] const projectMemberIds = memberProfiles.map((profile) => profile.userId).filter(Boolean) as string[]
await addMembers(room.id, [req.user.user_id, ...projectMemberIds]) await addMembers(room.id, [req.user.user_id, ...projectMemberIds])
@@ -386,11 +399,13 @@ export default async function communicationRoutes(server: FastifyInstance) {
.innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id)) .innerJoin(communicationRoomMembers, eq(communicationRoomMembers.roomId, communicationRooms.id))
.where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id))) .where(and(eq(communicationRooms.tenantId, tenantId), eq(communicationRooms.type, "direct"), eq(communicationRoomMembers.userId, req.user.user_id)))
const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room])) const roomsByKey = new Map(existingRows.map(({ room }) => [room.key, room]))
const activity = await withActivity(existingRows)
return { rooms: users.map((user) => { return { rooms: users.map((user) => {
const key = directRoomKey(req.user.user_id, user.userId) const key = directRoomKey(req.user.user_id, user.userId)
const room = roomsByKey.get(key) const room = roomsByKey.get(key)
return room ? { return room ? {
...roomToApi(room), ...roomToApi(room),
lastActivityAt: activity(room),
name: displayName(user), name: displayName(user),
topic: `Direktnachricht mit ${displayName(user)}`, topic: `Direktnachricht mit ${displayName(user)}`,
entityUuid: user.userId, entityUuid: user.userId,

View File

@@ -44,7 +44,8 @@ export default async function devicesRFIDRoutes(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.tenant_id, device.tenant), eq(authProfiles.tenant_id, device.tenant),
eq(authProfiles.token_id, rfid_id) eq(authProfiles.token_id, rfid_id),
eq(authProfiles.active, true)
) )
) )
.limit(1) .limit(1)

View File

@@ -22,6 +22,7 @@ import {citys, files} from "../../db/schema";
import {and, eq, isNull, not} from "drizzle-orm"; import {and, eq, isNull, not} from "drizzle-orm";
import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service"; import {cancelManualGeneration, executeManualGeneration, finishManualGeneration} from "../modules/serialexecution.service";
import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service"; import { updateOutgoingDocumentCostCentres } from "../modules/outgoing-document-cost-centres.service";
import { updateIncomingInvoiceCostCentres } from "../modules/incoming-invoice-cost-centres.service";
import { s3 } from "../utils/s3"; import { s3 } from "../utils/s3";
import { secrets } from "../utils/secrets"; import { secrets } from "../utils/secrets";
import { storeExtractedTextForFile } from "../utils/documentText"; import { storeExtractedTextForFile } from "../utils/documentText";
@@ -334,6 +335,26 @@ export default async function functionRoutes(server: FastifyInstance) {
} }
}) })
server.put('/functions/incoming-invoices/:id/cost-centres', async (req, reply) => {
try {
const { id } = req.params as { id: string }
const invoiceId = Number(id)
if (!Number.isFinite(invoiceId)) return reply.code(400).send({ error: "Ungültige Eingangsbeleg-ID" })
const invoice = await updateIncomingInvoiceCostCentres(
server,
req.user.tenant_id,
req.user.user_id,
invoiceId,
req.body as any,
)
return { invoice }
} catch (error) {
const statusCode = (error as any)?.statusCode || 500
return reply.code(statusCode).send({ error: error instanceof Error ? error.message : "Kostenstellen konnten nicht geändert werden" })
}
})
server.post('/functions/services/bankstatementsync', async (req, reply) => { server.post('/functions/services/bankstatementsync', async (req, reply) => {
const result = await server.services.bankStatements.run(req.user.tenant_id); const result = await server.services.bankStatements.run(req.user.tenant_id);
if (result.errors.length > 0) { if (result.errors.length > 0) {
@@ -348,8 +369,11 @@ export default async function functionRoutes(server: FastifyInstance) {
}) })
server.post('/functions/services/prepareincominginvoices', async (req, reply) => { server.post('/functions/services/prepareincominginvoices', async (req, reply) => {
return reply.code(202).send(server.services.prepareIncomingInvoices.start(req.user.tenant_id))
})
await server.services.prepareIncomingInvoices.run(req.user.tenant_id) server.get('/functions/services/prepareincominginvoices', async (req) => {
return server.services.prepareIncomingInvoices.status(req.user.tenant_id)
}) })
server.get('/functions/liquidity-forecast', async (req, reply) => { server.get('/functions/liquidity-forecast', async (req, reply) => {

View File

@@ -7,7 +7,7 @@ import {
tenants tenants
} from "../../../db/schema" } from "../../../db/schema"
import {and, eq, inArray} from "drizzle-orm" import {and, eq, inArray, isNull} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../../utils/profileBranches" import { enrichProfilesWithBranches } from "../../utils/profileBranches"
import { enrichProfilesWithTeams } from "../../utils/profileTeams" import { enrichProfilesWithTeams } from "../../utils/profileTeams"
@@ -52,7 +52,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
const users = await server.db const users = await server.db
.select() .select()
.from(authUsers) .from(authUsers)
.where(inArray(authUsers.id, userIds)) .where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at)))
// 3) auth_profiles pro Tenant laden // 3) auth_profiles pro Tenant laden
const profileRows = await server.db const profileRows = await server.db
@@ -61,7 +61,9 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.tenant_id, tenantId), eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds) inArray(authProfiles.user_id, userIds),
eq(authProfiles.active, true),
isNull(authProfiles.archived_at)
)) ))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
@@ -98,7 +100,7 @@ export default async function tenantRoutesInternal(server: FastifyInstance) {
const profileRows = await server.db const profileRows = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId)) .where(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.archived_at)))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
return await enrichProfilesWithTeams(server, profilesWithBranches) return await enrichProfilesWithTeams(server, profilesWithBranches)

View File

@@ -6,6 +6,16 @@ import { assertToolPermission, createMcpContext } from "../mcp/authz"
import { mcpToolMap, mcpTools } from "../mcp/registry" import { mcpToolMap, mcpTools } from "../mcp/registry"
import { asToolError, asToolResult } from "../mcp/result" import { asToolError, asToolResult } from "../mcp/result"
import { JsonRpcRequest } from "../mcp/types" import { JsonRpcRequest } from "../mcp/types"
import { assertInternalDocumentAccess, loadDocumentFile, MCP_BODY_LIMIT, MCP_FILE_PERMISSION } from "../mcp/documentFiles"
import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
import { GetObjectCommand } from "@aws-sdk/client-s3"
import { sanitizeFilename } from "../utils/filename"
import multipart from "@fastify/multipart"
import { MCP_FILE_LIMIT } from "../mcp/documentFiles"
import { parseUploadQuery, storeMcpFileUpload } from "../mcp/fileUploads"
const RAW_UPLOAD_TYPES = ["application/pdf", "application/octet-stream", "application/xml", "text/xml", "image/jpeg", "image/png", "image/tiff"]
const SUPPORTED_PROTOCOL_VERSIONS = [ const SUPPORTED_PROTOCOL_VERSIONS = [
"2025-11-25", "2025-11-25",
@@ -57,7 +67,13 @@ const requireMcpTokenManagementPermission = (req: any) => {
throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 }) throw Object.assign(new Error("Fehlende Berechtigung: mcp.tokens.write"), { statusCode: 403 })
} }
export default async function mcpRoutes(server: FastifyInstance) { type McpRouteOptions = {
/** Replaceable in tests to avoid object storage. */
storeUpload?: typeof storeMcpFileUpload
}
export default async function mcpRoutes(server: FastifyInstance, options: McpRouteOptions = {}) {
const storeUpload = options.storeUpload || storeMcpFileUpload
server.get("/mcp/tokens", async (req, reply) => { server.get("/mcp/tokens", async (req, reply) => {
try { try {
requireMcpTokenManagementPermission(req) requireMcpTokenManagementPermission(req)
@@ -195,7 +211,59 @@ export default async function mcpRoutes(server: FastifyInstance) {
} }
}) })
server.post("/mcp", async (req, reply) => { server.get<{ Params: { id: string } }>("/mcp/files/:id/content", {
schema: { params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } } },
}, async (req, reply) => {
try {
const context = await createMcpContext(server, req)
assertToolPermission(context, { name: "documents.files.download", requiredPermissions: [MCP_FILE_PERMISSION] })
await assertInternalDocumentAccess(context)
const file = await loadDocumentFile(context, req.params.id)
if (!file.path) return reply.code(404).send({ error: "Kein Dateiinhalt verfügbar." })
const { Body } = await s3.send(new GetObjectCommand({ Bucket: secrets.S3_BUCKET, Key: file.path }))
if (!Body) return reply.code(404).send({ error: "Kein Dateiinhalt verfügbar." })
reply.header("Content-Type", file.mimeType || "application/octet-stream")
reply.header("Content-Disposition", `attachment; filename="${sanitizeFilename(file.name)}"`)
reply.header("Cache-Control", "no-store")
reply.header("X-Content-Type-Options", "nosniff")
return reply.send(Body)
} catch (error) {
return reply.code((error as any)?.statusCode || 500).send({ error: error instanceof Error ? error.message : "Download fehlgeschlagen." })
}
})
// Binary uploads without Base64; parsers stay scoped so JSON-RPC parsing is unaffected.
await server.register(async (uploads) => {
await uploads.register(multipart, { limits: { fileSize: MCP_FILE_LIMIT, files: 1 } })
uploads.addContentTypeParser(RAW_UPLOAD_TYPES, { parseAs: "buffer", bodyLimit: MCP_FILE_LIMIT }, (_req, body, done) => done(null, body))
uploads.post("/mcp/files", async (req, reply) => {
try {
const context = await createMcpContext(server, req)
const query = parseUploadQuery(req.query as Record<string, unknown>)
let body: Buffer
let filename = query.filename
let mimeType = req.headers["content-type"]?.split(";")[0].trim() || null
if (req.isMultipart()) {
const part = await req.file()
if (!part || part.fieldname !== "file") return reply.code(400).send({ error: "Bitte die Datei im Feld file übertragen." })
body = await part.toBuffer()
filename ||= part.filename
mimeType = part.mimetype
} else if (Buffer.isBuffer(req.body)) {
body = req.body
} else {
return reply.code(415).send({ error: `Bitte multipart/form-data oder einen der Content-Types ${RAW_UPLOAD_TYPES.join(", ")} verwenden.` })
}
if (!filename) return reply.code(400).send({ error: "filename ist erforderlich." })
return await storeUpload(context, { ...query, filename, body, mimeType })
} catch (error) {
return reply.code((error as any)?.statusCode || 400).send({ error: error instanceof Error ? error.message : "Upload fehlgeschlagen." })
}
})
})
server.post("/mcp", { bodyLimit: MCP_BODY_LIMIT }, async (req, reply) => {
const body = req.body as JsonRpcRequest | JsonRpcRequest[] const body = req.body as JsonRpcRequest | JsonRpcRequest[]
const requests = Array.isArray(body) ? body : [body] const requests = Array.isArray(body) ? body : [body]
const responses = [] const responses = []
@@ -226,7 +294,7 @@ export default async function mcpRoutes(server: FastifyInstance) {
name: "fedeo-mcp", name: "fedeo-mcp",
version: "1.0.0", version: "1.0.0",
}, },
instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.", instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs-, Organisations- und Dokumentenwerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten. Dokumentenuploads erscheinen in der Dokumentenablage; Download-URLs benötigen denselben Bearer-Token wie MCP.",
})) }))
continue continue
} }
@@ -244,7 +312,7 @@ export default async function mcpRoutes(server: FastifyInstance) {
description: tool.description, description: tool.description,
inputSchema: tool.inputSchema, inputSchema: tool.inputSchema,
annotations: { annotations: {
readOnlyHint: !tool.requiredPermissions.some((permission) => permission.endsWith(".write")), readOnlyHint: tool.readOnly ?? !tool.requiredPermissions.some((permission) => permission.endsWith(".write")),
}, },
})), })),
})) }))

View File

@@ -1,5 +1,5 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { eq, and } from "drizzle-orm"; import { eq, and, isNull } from "drizzle-orm";
import { import {
authProfiles, authProfiles,
@@ -57,7 +57,7 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
const forbidden = [ const forbidden = [
"id", "user_id", "tenant_id", "created_at", "updated_at", "id", "user_id", "tenant_id", "created_at", "updated_at",
"updatedAt", "updatedBy", "old_profile_id", "full_name", "updatedAt", "updatedBy", "old_profile_id", "full_name",
"branch", "calendar_subscription_token", "branch", "archived_at", "calendar_subscription_token",
"calendar_subscription_path", "calendar_subscription_url" "calendar_subscription_path", "calendar_subscription_url"
] ]
forbidden.forEach(f => delete cleaned[f]) forbidden.forEach(f => delete cleaned[f])
@@ -135,7 +135,8 @@ export default async function authProfilesRoutes(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.id, id), eq(authProfiles.id, id),
eq(authProfiles.tenant_id, tenantId) eq(authProfiles.tenant_id, tenantId),
isNull(authProfiles.archived_at)
) )
) )
.returning() .returning()

View File

@@ -9,12 +9,14 @@ import {
and, and,
count, count,
inArray, inArray,
isNull,
or, or,
sql, sql,
} from "drizzle-orm" } from "drizzle-orm"
import { import {
authProfiles, authProfiles,
authUsers,
createddocuments, createddocuments,
customers, customers,
entitybankaccounts, entitybankaccounts,
@@ -138,7 +140,10 @@ function buildFieldUpdateHistoryText(resource: string, label: string, oldValue:
return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"` return `${resourceLabel}: ${label} geändert von "${formatDiffValue(oldValue)}" zu "${formatDiffValue(newValue)}"`
} }
function applyResourceWhereFilters(resource: string, table: any, whereCond: any) { function applyResourceWhereFilters(resource: string, table: any, whereCond: any, list = false) {
if (list && table === authProfiles) {
return and(whereCond, eq(authProfiles.active, true), isNull(authProfiles.archived_at))
}
if (resource === "members") { if (resource === "members") {
return and(whereCond, eq(table.type, "Mitglied")) return and(whereCond, eq(table.type, "Mitglied"))
} }
@@ -570,7 +575,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table) const tenantColumn = getTenantColumn(resource, table)
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyResourceWhereFilters(resource, table, whereCond, true)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
let q = server.db.select().from(table).$dynamic() let q = server.db.select().from(table).$dynamic()
@@ -688,7 +693,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const tenantColumn = getTenantColumn(resource, table); const tenantColumn = getTenantColumn(resource, table);
let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined; let whereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined;
whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyResourceWhereFilters(resource, table, whereCond, true)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
const searchCols: any[] = (config.searchColumns || []).map(c => table[c]); const searchCols: any[] = (config.searchColumns || []).map(c => table[c]);
const debugSearchColumnNames: string[] = [...(config.searchColumns || [])]; const debugSearchColumnNames: string[] = [...(config.searchColumns || [])];
@@ -789,7 +794,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
}); });
} }
let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined let distinctWhereCond: any = tenantColumn ? eq(tenantColumn, tenantId) : undefined
distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond) distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond, true)
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId) distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
if (search) { if (search) {
@@ -878,7 +883,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
const table = resourceConfig[resource].table const table = resourceConfig[resource].table
let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) let whereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
@@ -943,6 +948,15 @@ export default async function resourceRoutes(server: FastifyInstance) {
const table = config.table; const table = config.table;
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false }; let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
if (table === authProfiles) {
delete createData.archived_at;
createData.tenant_id = req.user.tenant_id;
if (createData.user_id) {
const [owner] = await server.db.select({ id: authUsers.id }).from(authUsers)
.where(and(eq(authUsers.id, createData.user_id), isNull(authUsers.archived_at))).limit(1);
if (!owner) return reply.code(409).send({ error: "Aktiver Benutzer nicht gefunden." });
}
}
if (resource === "projects") { if (resource === "projects") {
const error = projectChatSettingError(req, createData) const error = projectChatSettingError(req, createData)
if (error) return reply.code(error.statusCode).send({ error: error.error }) if (error) return reply.code(error.statusCode).send({ error: error.error })
@@ -1093,7 +1107,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
const [oldRecord] = await server.db const [oldRecord] = await server.db
.select() .select()
.from(table) .from(table)
.where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId)) .where(applyPortalScope(resource, table, and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)), portalCustomerId))
.limit(1) .limit(1)
if (!oldRecord) { if (!oldRecord) {
@@ -1105,7 +1119,13 @@ export default async function resourceRoutes(server: FastifyInstance) {
if (error) return reply.code(error.statusCode).send({ error: error.error }) if (error) return reply.code(error.statusCode).send({ error: error.error })
} }
if (table === authProfiles && oldRecord.archived_at) {
return reply.code(409).send({ error: "Archivierte Profile können nicht mehr bearbeitet werden." })
}
let data: Record<string, any> = { ...body } let data: Record<string, any> = { ...body }
if (table === authProfiles) {
for (const key of ["archived_at", "user_id", "tenant_id", "id"]) delete data[key]
}
delete data.updatedAt delete data.updatedAt
delete data.updated_at delete data.updated_at
delete data.updatedBy delete data.updatedBy
@@ -1194,7 +1214,8 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
}) })
let updateWhereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId)) let updateWhereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId))
if (table === authProfiles) updateWhereCond = and(updateWhereCond, isNull(authProfiles.archived_at))
updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond) updateWhereCond = applyResourceWhereFilters(resource, table, updateWhereCond)
const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning() const [updated] = await server.db.update(table).set(data).where(updateWhereCond).returning()
@@ -1203,7 +1224,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
.update(table) .update(table)
.set({ defaultMandate: false }) .set({ defaultMandate: false })
.where(and( .where(and(
eq(table.tenant, tenantId), eq(getTenantColumn(resource, table), tenantId),
eq(table.customer, updated.customer), eq(table.customer, updated.customer),
sql`${table.id} <> ${updated.id}` sql`${table.id} <> ${updated.id}`
)) ))

View File

@@ -11,7 +11,7 @@ import {
m2mApiKeys m2mApiKeys
} from "../../db/schema" } from "../../db/schema"
import {and, desc, eq, inArray} from "drizzle-orm" import {and, desc, eq, inArray, isNull} from "drizzle-orm"
import { enrichProfilesWithBranches } from "../utils/profileBranches" import { enrichProfilesWithBranches } from "../utils/profileBranches"
import { enrichProfilesWithTeams } from "../utils/profileTeams" import { enrichProfilesWithTeams } from "../utils/profileTeams"
import { rotateRefreshToken } from "../utils/authTokens" import { rotateRefreshToken } from "../utils/authTokens"
@@ -152,7 +152,7 @@ export default async function tenantRoutes(server: FastifyInstance) {
const users = await server.db const users = await server.db
.select() .select()
.from(authUsers) .from(authUsers)
.where(inArray(authUsers.id, userIds)) .where(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at)))
// 3) auth_profiles pro Tenant laden // 3) auth_profiles pro Tenant laden
const profileRows = await server.db const profileRows = await server.db
@@ -161,7 +161,9 @@ export default async function tenantRoutes(server: FastifyInstance) {
.where( .where(
and( and(
eq(authProfiles.tenant_id, tenantId), eq(authProfiles.tenant_id, tenantId),
inArray(authProfiles.user_id, userIds) inArray(authProfiles.user_id, userIds),
eq(authProfiles.active, true),
isNull(authProfiles.archived_at)
)) ))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const profiles = await enrichProfilesWithTeams(server, profilesWithBranches) const profiles = await enrichProfilesWithTeams(server, profilesWithBranches)
@@ -194,10 +196,14 @@ export default async function tenantRoutes(server: FastifyInstance) {
const tenantId = req.user?.tenant_id const tenantId = req.user?.tenant_id
if (!tenantId) return reply.code(401).send({ error: "Unauthorized" }) if (!tenantId) return reply.code(401).send({ error: "Unauthorized" })
const includeArchived = (req.query as { includeArchived?: string }).includeArchived === "true"
const profileRows = await server.db const profileRows = await server.db
.select() .select()
.from(authProfiles) .from(authProfiles)
.where(eq(authProfiles.tenant_id, tenantId)) .where(and(
eq(authProfiles.tenant_id, tenantId),
...(includeArchived ? [] : [eq(authProfiles.active, true), isNull(authProfiles.archived_at)])
))
const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows) const profilesWithBranches = await enrichProfilesWithBranches(server, profileRows)
const data = await enrichProfilesWithTeams(server, profilesWithBranches) const data = await enrichProfilesWithTeams(server, profilesWithBranches)
@@ -271,6 +277,16 @@ export default async function tenantRoutes(server: FastifyInstance) {
if (!data) return reply.code(400).send({ error: "data required" }) if (!data) return reply.code(400).send({ error: "data required" })
if (Object.prototype.hasOwnProperty.call(data, "portalConfig")) {
const config = data.portalConfig
if (!config || typeof config !== "object" || Array.isArray(config)
|| typeof config.primaryColor !== "string" || !/^#[0-9a-f]{6}$/i.test(config.primaryColor.trim())) {
return reply.code(400).send({ error: "Die Portal-Akzentfarbe muss ein Hex-Farbwert wie #69c350 sein." })
}
data.portalConfig = { ...config, primaryColor: config.primaryColor.trim().toLowerCase() }
delete data.portalConfig.primayColor
}
const updated = await server.db const updated = await server.db
.update(tenants) .update(tenants)
.set(data) .set(data)

View File

@@ -70,7 +70,7 @@ export async function rotateRefreshToken(
const [user] = await server.db const [user] = await server.db
.select({ id: authUsers.id, email: authUsers.email }) .select({ id: authUsers.id, email: authUsers.email })
.from(authUsers) .from(authUsers)
.where(eq(authUsers.id, session.userId)) .where(and(eq(authUsers.id, session.userId), isNull(authUsers.archived_at)))
.limit(1) .limit(1)
if (!user) return null if (!user) return null

View File

@@ -1,5 +1,5 @@
import crypto from "crypto" import crypto from "crypto"
import { and, asc, eq, sql } from "drizzle-orm" import { and, asc, eq, isNull, sql } from "drizzle-orm"
import { FastifyInstance } from "fastify" import { FastifyInstance } from "fastify"
import { authProfiles, events } from "../../db/schema" import { authProfiles, events } from "../../db/schema"
@@ -105,7 +105,8 @@ export async function generateProfileCalendarSubscriptionToken(server: FastifyIn
.where( .where(
and( and(
eq(authProfiles.id, profileId), eq(authProfiles.id, profileId),
eq(authProfiles.tenant_id, tenantId) eq(authProfiles.tenant_id, tenantId),
isNull(authProfiles.archived_at)
) )
) )
.returning() .returning()

View File

@@ -2,17 +2,21 @@ import { GetObjectCommand } from "@aws-sdk/client-s3"
import { s3 } from "./s3" import { s3 } from "./s3"
import { secrets } from "./secrets" import { secrets } from "./secrets"
export const streamToBuffer = async (stream: any): Promise<Buffer> => { export const streamToBuffer = async (stream: any, maxBytes = Infinity): Promise<Buffer> => {
const chunks: Buffer[] = [] const chunks: Buffer[] = []
let size = 0
for await (const chunk of stream) { for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)) const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
size += buffer.length
if (size > maxBytes) throw new Error("Die Datei ist für Base64 zu groß. Bitte den Download mit format: url verwenden.")
chunks.push(buffer)
} }
return Buffer.concat(chunks) return Buffer.concat(chunks)
} }
export const loadFileBuffer = async (path: string): Promise<Buffer> => { export const loadFileBuffer = async (path: string, maxBytes = Infinity): Promise<Buffer> => {
const response = await s3.send(new GetObjectCommand({ const response = await s3.send(new GetObjectCommand({
Bucket: secrets.S3_BUCKET, Bucket: secrets.S3_BUCKET,
Key: path, Key: path,
@@ -22,5 +26,5 @@ export const loadFileBuffer = async (path: string): Promise<Buffer> => {
throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`) throw new Error(`S3-Datei '${path}' enthält keinen lesbaren Inhalt.`)
} }
return streamToBuffer(response.Body) return streamToBuffer(response.Body, maxBytes)
} }

View File

@@ -50,7 +50,7 @@ export async function insertHistoryItem(
created_by: string | null created_by: string | null
entity: string entity: string
entityId: string | number entityId: string | number
action: "created" | "updated" | "unchanged" | "deleted" | "archived" action: "created" | "updated" | "unchanged" | "deleted" | "archived" | "restored"
oldVal?: any oldVal?: any
newVal?: any newVal?: any
text?: string text?: string
@@ -62,6 +62,7 @@ export async function insertHistoryItem(
updated: `Eintrag in ${entityLabel} geändert`, updated: `Eintrag in ${entityLabel} geändert`,
unchanged: `Eintrag in ${entityLabel} unverändert`, unchanged: `Eintrag in ${entityLabel} unverändert`,
archived: `Eintrag in ${entityLabel} archiviert`, archived: `Eintrag in ${entityLabel} archiviert`,
restored: `Eintrag in ${entityLabel} wiederhergestellt`,
deleted: `Eintrag in ${entityLabel} gelöscht` deleted: `Eintrag in ${entityLabel} gelöscht`
} }

View File

@@ -0,0 +1,33 @@
import type { FastifyInstance } from "fastify"
import { and, eq } from "drizzle-orm"
import { authProfiles, authTenantUsers } from "../../db/schema"
export async function resolveLoginTenant(server: FastifyInstance, userId: string, domainTenantId: number | null) {
const memberships = await server.db
.selectDistinct({
tenantId: authTenantUsers.tenant_id,
portalCustomerId: authProfiles.customer_for_portal,
})
.from(authTenantUsers)
.leftJoin(authProfiles, and(
eq(authProfiles.user_id, authTenantUsers.user_id),
eq(authProfiles.tenant_id, authTenantUsers.tenant_id),
eq(authProfiles.active, true)
))
.where(eq(authTenantUsers.user_id, userId))
if (domainTenantId) {
if (!memberships.some((membership) => membership.tenantId === domainTenantId)) {
throw Object.assign(new Error("Not a member of this tenant"), { statusCode: 403 })
}
return domainTenantId
}
// Portal invitations assign one customer account. Load its profile immediately,
// even when the customer signs in through the shared FEDEO address.
if (memberships.length === 1 && memberships[0].portalCustomerId) {
return memberships[0].tenantId
}
return null
}

View File

@@ -488,6 +488,8 @@ export const createInvoicePDF = async (server:FastifyInstance, returnMode, invoi
const getTitleKey = (row) => `${row.pos} - ${row.text}` const getTitleKey = (row) => `${row.pos} - ${row.text}`
const getTransferSumText = (row) => { const getTransferSumText = (row) => {
if (deliveryNoteLikeDocumentTypes.includes(invoiceData.type)) return null
const transferSum = invoiceData.total.titleSumsTransfer[getTitleKey(row)] const transferSum = invoiceData.total.titleSumsTransfer[getTitleKey(row)]
return transferSum ? `Übertrag: ${transferSum}` : null return transferSum ? `Übertrag: ${transferSum}` : null

View File

@@ -0,0 +1,29 @@
type PortalUrlSettings = {
portalDomain?: string | null
publicUrl?: string
apiBaseUrl?: string
}
/** Use deployment configuration, never request headers, for links sent by email. */
export function getPortalLoginUrl({ portalDomain, publicUrl, apiBaseUrl }: PortalUrlSettings) {
const domain = portalDomain?.trim()
const candidates = [
{ value: domain ? (/^https?:\/\//i.test(domain) ? domain : `https://${domain}`) : undefined },
{ value: publicUrl?.trim() },
{ value: apiBaseUrl?.trim(), originOnly: true },
]
for (const { value, originOnly } of candidates) {
if (!value) continue
try {
const url = new URL(value)
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password) continue
url.pathname = `${originOnly ? "" : url.pathname.replace(/\/+$/, "")}/login`
url.search = ""
url.hash = ""
return url.toString()
} catch { /* Try the next configured address. */ }
}
throw new Error("Für die Portal-Einladung fehlt eine gültige öffentliche FEDEO-Adresse.")
}

View File

@@ -1,5 +1,5 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { eq } from "drizzle-orm"; import { and, eq, isNull } from "drizzle-orm";
import { authUsers } from "../../db/schema/auth_users"; import { authUsers } from "../../db/schema/auth_users";
export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) { export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRequest, reply: FastifyReply) {
@@ -9,7 +9,7 @@ export async function requireSystemAdmin(server: FastifyInstance, req: FastifyRe
} }
// Read the current global role; tenant roles and stale JWT claims do not grant access. // Read the current global role; tenant roles and stale JWT claims do not grant access.
const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin }) const [user] = await server.db.select({ id: authUsers.id, email: authUsers.email, is_admin: authUsers.is_admin })
.from(authUsers).where(eq(authUsers.id, req.user.user_id)).limit(1); .from(authUsers).where(and(eq(authUsers.id, req.user.user_id), isNull(authUsers.archived_at))).limit(1);
if (!user?.is_admin) { if (!user?.is_admin) {
reply.code(403).send({ error: "Admin access required" }); reply.code(403).send({ error: "Admin access required" });
return null; return null;

View File

@@ -0,0 +1,24 @@
import assert from "node:assert/strict"
import test from "node:test"
import { calculateAccountBalance, CASHBOOK_BANK_ID } from "../src/modules/bank-balances.service"
const syncedAt = new Date("2026-10-20T08:00:00Z")
const bank = { id: 1, name: "Giro", iban: "DE00", ownerName: null, bankId: "bank", datevNumber: "1200", balance: 1000.1, expired: false, syncedAt }
const cashbook = { ...bank, id: 2, name: "Kasse", iban: "CASH-1", bankId: CASHBOOK_BANK_ID, datevNumber: "1000", balance: 50 }
test("bank accounts report the synced balance and revert later statements for a past date", () => {
assert.equal(calculateAccountBalance(bank, 0, null).balance, 1000.1)
assert.equal(calculateAccountBalance(bank, 0, null).source, "bank_sync")
const past = calculateAccountBalance(bank, -200.05, "2026-10-01")
assert.equal(past.balance, 1200.15)
assert.equal(past.syncedBalance, 1000.1)
assert.equal(past.source, "bank_sync_reconstructed")
})
test("cashbooks add their bookings to the opening balance", () => {
const result = calculateAccountBalance(cashbook, 12.345, null)
assert.equal(result.type, "cashbook")
assert.equal(result.balance, 62.35)
assert.equal(result.openingBalance, 50)
assert.equal(result.iban, null)
})

View File

@@ -0,0 +1,201 @@
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 { eq } from "drizzle-orm"
import { notificationsItems } from "../db/schema"
import { CollaborationService } from "../src/modules/collaboration/service"
import { FederationService, federationRoutes } from "../src/modules/collaboration/federation"
import { CollaborationNotifications } from "../src/modules/collaboration/notifications"
import type { Transport } from "../src/modules/collaboration/transport"
import { collaborationDatabase, testDatabaseUrl } from "./helpers/collaboration-db"
const MESSAGE = "collaboration.message.new", INVITATION = "collaboration.room.invited"
test("collaboration notifications use real preferences, transactions and signed federation routes", { skip: !testDatabaseUrl }, async t => {
const servers = new Map<string, ReturnType<typeof Fastify>>()
let offline = false
const transport: Transport = async (url, method, headers, body) => {
if (offline) throw Object.assign(new Error("Offline"), { statusCode: 503 })
const origin = [...servers.keys()].find(value => url.startsWith(value + "/"))!
const response = await servers.get(origin)!.inject({ method, url: url.slice(origin.length), headers, ...(body ? { payload: body } : {}) })
if (response.statusCode >= 400) throw Object.assign(new Error(response.json().message), { statusCode: response.statusCode })
return response.json()
}
const make = async (name: string) => {
const fixture = await collaborationDatabase(["0075_project_collaboration.sql", "0076_collaboration_federation.sql"])
const original = await readFile(new URL("../db/migrations/0000_brief_dark_beast.sql", import.meta.url), "utf8")
for (const type of ["notification_channel", "notification_status", "notification_severity"]) {
const definition = original.match(new RegExp(`CREATE TYPE "public"\\."${type}" AS ENUM\\([^;]+;`))![0]
await fixture.pool.query(definition.replace('"public".', ""))
}
for (const table of ["notifications_event_types", "notifications_items", "notifications_preferences", "notifications_preferences_defaults"]) {
await fixture.pool.query(original.match(new RegExp(`CREATE TABLE "${table}" \\([\\s\\S]*?\\n\\);`))![0])
}
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences(tenant_id,user_id,event_type,channel)")
await fixture.pool.query("CREATE UNIQUE INDEX ON notifications_preferences_defaults(tenant_id,event_key,channel)")
await fixture.pool.query(await readFile(new URL("../db/migrations/0083_collaboration_notifications.sql", import.meta.url), "utf8"))
const userId = randomUUID()
await fixture.pool.query("INSERT INTO tenants VALUES (1,$1,null,null),(2,'Other tenant',null,null)", [name])
await fixture.pool.query("INSERT INTO auth_users(id,email,is_admin) VALUES ($1,$2,true)", [userId, `${name}@example.test`])
await fixture.pool.query("INSERT INTO auth_tenant_users(tenant_id,user_id) VALUES (1,$1),(2,$1)", [userId])
const service = new CollaborationService(fixture.db, { publicUrl: `https://${name}.example`, guestPepper: () => "a".repeat(64), sendMail: async () => ({ success: true }) })
const federation = new FederationService(service, { apiUrl: `https://${name}.example/backend`, enabled: true, transport,
seal: value => ({ key: value }), unseal: value => value.key })
service.options.changed = (id, db) => federation.changed(id, db)
service.options.invited = (ctx, roomId, target, invitationId, db) => federation.invite(ctx, roomId, target, invitationId, db)
const app = Fastify()
app.decorate("db", fixture.db)
const notifications = new CollaborationNotifications(app, service, federation)
service.options.notifications = notifications
const delivered: any[] = []
const delivery = notifications.delivery.bind(notifications)
// Keep persistence and preference resolution real; replace only external channel delivery.
notifications.delivery = db => {
const sender = delivery(db)
sender.deliver = async item => {
delivered.push(item)
await db.update(notificationsItems).set({ status: "sent", sentAt: new Date() }).where(eq(notificationsItems.id, item.id))
return { success: true, id: item.id, channel: item.channel }
}
return sender
}
await app.register(federationRoutes, { federation })
await app.ready()
servers.set(federation.origin, app)
return { ...fixture, service, federation, notifications, app, delivered, ctx: await service.userContext(userId, 1), other: await service.userContext(userId, 2),
items: async () => (await fixture.pool.query("SELECT * FROM notifications_items ORDER BY created_at,id")).rows }
}
const a = await make("alpha"), b = await make("beta")
try {
await a.federation.settings(a.ctx, { origin: b.federation.origin, active: true })
await b.federation.settings(b.ctx, { origin: a.federation.origin, active: true })
const contact = await a.federation.resolveContact(a.ctx, b.federation.contact(b.ctx.principal))
const room = await a.service.createRoom(a.ctx, { name: "Gemeinsamer Chat" })
const before = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Private history" })
let bindingId: string
const jobFor = async (invitationId: string) => (await a.pool.query("SELECT * FROM collaboration_outbox WHERE dedup_key=$1", [`invite:${invitationId}`])).rows[0]
const itemsFor = async (instance: typeof a, id: string) => (await instance.items()).filter(item => item.payload.messageId === id)
const enable = async (instance: typeof a, tenantId = 1, enabled = true) => instance.notifications.delivery(instance.db).savePreferences(tenantId, instance.ctx.userId,
["email", "push"].map(channel => ({ eventType: MESSAGE, channel, enabled })))
await t.test("new matrix events have quiet message defaults and per-tenant overrides", async () => {
const preferences = await b.notifications.delivery(b.db).preferences(1, b.ctx.userId)
assert.deepEqual(preferences.events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
assert.deepEqual(preferences.events.find(e => e.eventKey === INVITATION)!.channels, { email: false, push: true })
await enable(a); await enable(b)
assert.deepEqual((await b.notifications.delivery(b.db).preferences(2, b.ctx.userId)).events.find(e => e.eventKey === MESSAGE)!.channels, { email: false, push: false })
})
await t.test("signed invitations notify only their named recipient, once despite redelivery", async () => {
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
const job = await jobFor(invitation.id)
await a.federation.deliver(job)
bindingId = (await b.federation.list(b.ctx)).invitations[0].id
await a.pool.query("UPDATE collaboration_outbox SET status='pending' WHERE id=$1", [job.id])
await a.federation.deliver(job)
const items = await b.items()
assert.equal(items.length, 2)
assert.deepEqual(items.map(i => i.channel).sort(), ["inapp", "push"])
assert.ok(items.every(i => i.user_id === b.ctx.userId && Number(i.tenant_id) === 1 && i.event_type === INVITATION))
assert.equal((await a.items()).length, 0)
await b.notifications.tick()
assert.equal(b.delivered.length, 2)
await b.federation.accept(b.ctx, bindingId)
assert.equal((await itemsFor(b, before.id)).length, 0)
})
await t.test("new remote messages notify once with the local room link, including simultaneous syncs", async () => {
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Hallo Beta" })
await Promise.all([b.federation.memberRequest(b.ctx, bindingId, "messages"), b.federation.memberRequest(b.ctx, bindingId, "messages")])
const items = await itemsFor(b, message.id)
assert.equal(items.length, 3)
assert.ok(items.every(i => i.message.includes("Hallo Beta") && i.payload.link === `/communication/chat?room=collaboration%3A${bindingId}`))
assert.equal((await itemsFor(a, message.id)).length, 0)
await b.federation.memberRequest(b.ctx, bindingId, "messages", { before: message.seq + 1 })
assert.equal((await itemsFor(b, message.id)).length, 3)
await b.notifications.tick()
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 3)
})
await t.test("remote replies notify the owner's local members, never the sender or retry twice", async () => {
const input = { clientId: randomUUID(), text: "Hallo Alpha" }
const reply = (await b.federation.memberRequest(b.ctx, bindingId, "send", input)).result
await b.federation.memberRequest(b.ctx, bindingId, "send", input)
await b.federation.memberRequest(b.ctx, bindingId, "messages")
assert.equal((await itemsFor(a, reply.id)).length, 3)
assert.equal((await itemsFor(b, reply.id)).length, 0)
assert.ok((await itemsFor(a, reply.id)).every(i => Number(i.tenant_id) === 1 && i.user_id === a.ctx.userId))
await a.notifications.tick()
})
await t.test("reading before delivery suppresses email and push, and new preferences take effect", async () => {
const read = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Already read" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
await b.federation.memberRequest(b.ctx, bindingId, "read", { seq: read.seq })
await b.notifications.tick()
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === read.id).map(i => i.channel), ["inapp"])
const muted = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Muted before delivery" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
await enable(b, 1, false)
await b.notifications.tick()
assert.deepEqual(b.delivered.filter(i => i.payload.messageId === muted.id).map(i => i.channel), ["inapp"])
await enable(b)
})
await t.test("offline jobs survive a worker restart and concurrent workers deliver each item once", async () => {
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Wait for access confirmation" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
offline = true
await b.notifications.tick()
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
assert.ok((await itemsFor(b, message.id)).every(i => i.status === "queued"))
offline = false
const restarted = new CollaborationNotifications(b.app, b.service, b.federation)
restarted.delivery = b.notifications.delivery
await Promise.all([b.notifications.tick(), restarted.tick()])
const delivered = b.delivered.filter(i => i.payload.messageId === message.id)
assert.equal(delivered.length, 3)
assert.equal(new Set(delivered.map(i => i.id)).size, 3)
})
await t.test("revoked access cancels queued remote messages and reinvitation cannot replay old ones", async () => {
const message = await a.service.send(a.ctx.principal, room.id, { clientId: randomUUID(), text: "Revoked before delivery" })
await b.federation.memberRequest(b.ctx, bindingId, "messages")
const member = (await a.service.management(a.ctx, room.id)).members.find(m => m.principalId === contact.id)
await a.service.revoke(a.ctx, room.id, member.id)
await b.notifications.tick()
assert.equal(b.delivered.filter(i => i.payload.messageId === message.id).length, 0)
assert.equal((await itemsFor(b, message.id)).length, 0)
const invitation = await a.service.invite(a.ctx, room.id, contact, {})
await a.federation.deliver(await jobFor(invitation.id))
await b.federation.accept(b.ctx, bindingId)
await b.notifications.tick()
assert.equal((await itemsFor(b, message.id)).length, 0)
})
await t.test("local cross-tenant invitations and messages use recipient scope and honor expiry", async () => {
const local = await a.service.createRoom(a.ctx, { name: "Tenant exchange" })
const invitation = await a.service.invite(a.ctx, local.id, a.other.principal, {})
const invites = (await a.items()).filter(i => i.payload.invitationId === invitation.id)
assert.equal(invites.length, 2)
assert.ok(invites.every(i => Number(i.tenant_id) === 2))
await a.service.accept(a.other.principal, invitation.id)
const first = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Only tenant two" })
assert.deepEqual((await itemsFor(a, first.id)).map(i => [Number(i.tenant_id), i.channel]), [[2, "inapp"]])
await a.pool.query("UPDATE collaboration_members SET expires_at=now()-interval '1 second' WHERE room_id=$1 AND principal_id=$2", [local.id, a.other.principal.id])
await a.notifications.tick()
assert.equal(a.delivered.filter(i => i.payload.messageId === first.id).length, 0)
const second = await a.service.send(a.ctx.principal, local.id, { clientId: randomUUID(), text: "Expired member" })
assert.equal((await itemsFor(a, second.id)).length, 0)
})
await t.test("message and notification records roll back together", async () => {
const hook = a.notifications.localMessage.bind(a.notifications)
a.notifications.localMessage = async (...args) => { await hook(...args); throw new Error("Rollback after enqueue") }
const clientId = randomUUID(), count = (await a.items()).length
try {
await assert.rejects(b.federation.call(1, a.federation.origin, "messages.send", { actorId: b.ctx.principal.id, roomId: room.id,
input: { clientId, text: "Must roll back", revision: Number((await b.federation.binding(b.ctx, bindingId)).revision) } }), /Rollback/)
} finally { a.notifications.localMessage = hook }
assert.equal((await a.items()).length, count)
assert.equal((await a.pool.query("SELECT id FROM collaboration_messages WHERE client_id=$1", [clientId])).rowCount, 0)
})
} finally {
await a.app.close(); await b.app.close()
await a.close(); await b.close()
}
})

View File

@@ -0,0 +1,126 @@
import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import cookie from "@fastify/cookie"
import bcrypt from "bcrypt"
import jwt from "jsonwebtoken"
import { drizzle } from "drizzle-orm/pg-proxy"
import authRoutes from "../src/routes/auth/auth"
import meRoutes from "../src/routes/auth/me"
import { secrets } from "../src/utils/secrets"
import { getPortalLoginUrl } from "../src/utils/portalUrl"
test("portal invitations always use a configured frontend login URL", () => {
const defaults = { apiBaseUrl: "https://fedeo.example/backend" }
assert.equal(getPortalLoginUrl(defaults), "https://fedeo.example/login")
assert.equal(getPortalLoginUrl({ ...defaults, portalDomain: " kunden.example " }), "https://kunden.example/login")
assert.equal(getPortalLoginUrl({ ...defaults, portalDomain: "https://kunden.example/" }), "https://kunden.example/login")
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "https://web.example/fedeo/" }), "https://web.example/fedeo/login")
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "http://localhost:3000/" }), "http://localhost:3000/login")
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "javascript:alert(1)" }), "https://fedeo.example/login")
assert.equal(getPortalLoginUrl({ ...defaults, publicUrl: "https://user:password@web.example/" }), "https://fedeo.example/login")
assert.throws(() => getPortalLoginUrl({}), /öffentliche FEDEO-Adresse/)
})
const userId = "00000000-0000-4000-8000-000000000001"
const profileId = "00000000-0000-4000-8000-000000000002"
const passwordHash = bcrypt.hashSync("portal-password", 4)
function selectedRow(sql: string, values: Record<string, any>) {
return sql.slice(7, sql.indexOf(" from ")).split(", ").map(column => {
const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1]
return values[name!] ?? null
})
}
async function fixture(t: any, memberships: Array<[number, number | null]>, domainTenantId?: number) {
const refreshTenants: Array<number | null> = []
const db = drizzle(async (sql, params) => {
if (sql.includes('from "auth_users"')) {
return { rows: [selectedRow(sql, {
id: userId, email: "customer@example.com", password_hash: passwordHash,
must_change_password: true, is_admin: false,
})] }
}
if (sql.includes('from "auth_tenant_users"') && sql.includes('"auth_profiles"')) {
assert.ok(params.includes(userId))
assert.match(sql, /"auth_profiles"\."tenant_id" = "auth_tenant_users"\."tenant_id"/)
assert.match(sql, /"auth_profiles"\."active" =/)
assert.ok(params.includes(true))
return { rows: memberships }
}
if (sql.startsWith('insert into "auth_refresh_tokens"')) {
refreshTenants.push(params[1] as number | null)
return { rows: [] }
}
if (sql.includes('from "auth_tenant_users"')) {
return { rows: memberships.map(([id]) => selectedRow(sql, { id, name: "Test tenant" })) }
}
if (sql.includes('from "auth_profiles"')) {
const membership = memberships.find(([id]) => params.includes(id))
return { rows: membership ? [selectedRow(sql, {
id: profileId, user_id: userId, tenant_id: membership[0],
customer_for_portal: membership[1], active: true,
})] : [] }
}
if (sql.includes('from "auth_profile_branches"') || sql.includes('from "auth_user_roles"')) return { rows: [] }
throw new Error(`Unexpected query: ${sql}`)
})
secrets.JWT_SECRET = "customer-portal-test-secret"
const app = Fastify()
app.decorate("db", db as any)
app.addHook("preHandler", async (req: any) => {
if (domainTenantId) req.tenant = { id: String(domainTenantId) }
if (req.headers.authorization) req.user = jwt.verify(req.headers.authorization.slice(7), secrets.JWT_SECRET)
})
await app.register(cookie)
await app.register(authRoutes)
await app.register(meRoutes)
t.after(() => app.close())
const login = (password = "portal-password") => app.inject({
method: "POST", url: "/auth/login", payload: { email: "customer@example.com", password },
})
return { app, login, refreshTenants }
}
test("shared-address login scopes both tokens and loads the customer's portal profile", async t => {
const f = await fixture(t, [[7, 123]])
const response = await f.login()
assert.equal(response.statusCode, 200, response.body)
const session = response.json()
assert.equal((jwt.verify(session.token, secrets.JWT_SECRET) as any).tenant_id, 7)
assert.deepEqual(f.refreshTenants, [7])
assert.ok(session.refreshToken)
const me = await f.app.inject({ url: "/me", headers: { authorization: `Bearer ${session.token}` } })
assert.equal(me.statusCode, 200, me.body)
assert.equal(me.json().activeTenant, 7)
assert.equal(me.json().profile.customer_for_portal, 123)
assert.equal(me.json().user.must_change_password, true)
})
test("internal and ambiguous accounts retain tenant selection", async t => {
for (const memberships of [[], [[7, null]], [[7, 123], [8, 456]]] as Array<Array<[number, number | null]>>) {
const f = await fixture(t, memberships)
const response = await f.login()
assert.equal(response.statusCode, 200, response.body)
assert.equal((jwt.verify(response.json().token, secrets.JWT_SECRET) as any).tenant_id, null)
assert.deepEqual(f.refreshTenants, [null])
}
})
test("a portal domain selects only a tenant the user belongs to", async t => {
const f = await fixture(t, [[7, 123], [8, 456]], 8)
const response = await f.login()
assert.equal(response.statusCode, 200, response.body)
assert.equal((jwt.verify(response.json().token, secrets.JWT_SECRET) as any).tenant_id, 8)
assert.deepEqual(f.refreshTenants, [8])
const other = await fixture(t, [[7, 123]], 99)
assert.equal((await other.login()).statusCode, 403)
assert.deepEqual(other.refreshTenants, [])
})
test("invalid credentials never create a portal session", async t => {
const f = await fixture(t, [[7, 123]])
assert.equal((await f.login("wrong-password")).statusCode, 401)
assert.deepEqual(f.refreshTenants, [])
})

View File

@@ -24,7 +24,7 @@ export async function collaborationDatabase(migrations: string[] = []) {
const columns = Object.values(getTableColumns(table)).map((column: any) => { const columns = Object.values(getTableColumns(table)).map((column: any) => {
let suffix = column.primary ? " PRIMARY KEY" : "" let suffix = column.primary ? " PRIMARY KEY" : ""
if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()" if (column.name === "id" && column.getSQLType() === "uuid") suffix += " DEFAULT gen_random_uuid()"
if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()" if (column.columnType === "PgTimestamp" && column.hasDefault) suffix += " DEFAULT now()"
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}` if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
return `"${column.name}" ${column.getSQLType()}${suffix}` return `"${column.name}" ${column.getSQLType()}${suffix}`
}) })

View File

@@ -0,0 +1,48 @@
import assert from "node:assert/strict"
import test from "node:test"
import {
applyAccountCostCentreAssignments,
normalizeAccountCostCentreAssignments,
} from "../src/modules/incoming-invoice-cost-centres.service"
import { mcpToolMap } from "../src/mcp/registry"
const CENTRE = "8f1c2b3a-4d5e-4f60-8a7b-9c0d1e2f3a4b"
test("changes only requested account cost centres", () => {
const accounts = [
{ account: 1, amountNet: 100, taxType: "19", costCentre: "old-1" },
{ account: 2, amountNet: 50, taxType: "7", costCentre: "old-2" },
]
assert.deepEqual(applyAccountCostCentreAssignments(accounts, [
{ index: 1, costcentre: CENTRE },
]), [
accounts[0],
{ account: 2, amountNet: 50, taxType: "7", costCentre: CENTRE },
])
})
test("rejects assignments for unknown invoice positions", () => {
assert.throws(
() => applyAccountCostCentreAssignments([{ account: 1 }], [{ index: 3, costcentre: null }]),
/Position 4.*nicht gefunden/,
)
})
test("validates cost centre assignments", () => {
assert.throws(() => normalizeAccountCostCentreAssignments([]), /Mindestens eine/)
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: -1, costcentre: null }]), /gültigen index/)
assert.throws(() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: "x" }]), /Ungültige Kostenstelle/)
assert.throws(
() => normalizeAccountCostCentreAssignments([{ index: 0, costcentre: null }, { index: 0, costcentre: CENTRE }]),
/mehrfach/,
)
assert.deepEqual(normalizeAccountCostCentreAssignments([{ index: 0, costcentre: CENTRE }]), [{ index: 0, costcentre: CENTRE }])
})
test("registers a dedicated incoming invoice cost centre tool", () => {
const tool = mcpToolMap.get("accounting.incoming_invoices.cost_centres.update")
assert.deepEqual(tool?.requiredPermissions, ["accounting.incoming_invoices.write"])
assert.deepEqual(tool?.inputSchema.required, ["id", "accountCostCentres"])
})

View File

@@ -0,0 +1,45 @@
import assert from "node:assert/strict"
import test from "node:test"
import { prepareIncomingInvoices } from "../src/modules/cron/prepareIncomingInvoices"
function server(tenantLookups: { count: number }) {
let release: () => void = () => undefined
const gate = new Promise<void>((resolve) => { release = resolve })
const db: any = {
select: () => ({
from: () => ({
where: () => ({
orderBy: async () => {
tenantLookups.count += 1
await gate
return []
},
}),
}),
}),
}
const log = { error: () => undefined, info: () => undefined, warn: () => undefined }
return { server: { db, log } as any, release: () => release() }
}
test("start runs the preparation in the background and does not start a second run", async () => {
const lookups = { count: 0 }
const { server: srv, release } = server(lookups)
const service = prepareIncomingInvoices(srv)
const first = service.start(7)
const second = service.start(7)
assert.equal(first.running, true)
assert.equal(second.startedAt, first.startedAt)
assert.equal(service.status(8).running, false)
release()
await new Promise((resolve) => setImmediate(resolve))
await new Promise((resolve) => setImmediate(resolve))
const status = service.status(7)
assert.equal(lookups.count, 1)
assert.equal(status.running, false)
assert.ok(status.finishedAt)
assert.equal(status.result?.error, "Mandant nicht gefunden")
})

View File

@@ -0,0 +1,99 @@
import assert from "node:assert/strict"
import test from "node:test"
import { getTableColumns, getTableName } from "drizzle-orm"
import { PgDialect } from "drizzle-orm/pg-core"
import * as schema from "../db/schema"
import { mcpToolMap } from "../src/mcp/registry"
import { archiveEntities, prepareArchiveRestoreInput } from "../src/mcp/tools/archive"
import { asToolResult } from "../src/mcp/result"
const tenantId = 7
const userId = "00000000-0000-4000-8000-000000000001"
const uuid = "00000000-0000-4000-8000-000000000002"
const dialect = new PgDialect()
/** Minimal Drizzle stand-in which evaluates equality predicates, including tenant filters. */
function fixture(initial: Record<string, any[]> = {}, listRows: any[] = []) {
const state = structuredClone(initial)
const rowsFor = (table: any) => state[getTableName(table)] ||= []
const matches = (table: any, where: any, row: any) => {
const query = dialect.sqlToQuery(where)
const columns = getTableColumns(table) as Record<string, any>
const keyFor = (name: string) => Object.keys(columns).find((key) => columns[key].name === name)!
return [...query.sql.matchAll(/"\w+"\."(\w+)" = \$(\d+)/g)]
.every((match) => row[keyFor(match[1])] === query.params[Number(match[2]) - 1])
}
const listed: string[] = []
const db: any = {
transaction: async (callback: any) => callback(db),
select: () => ({ from: (table: any) => {
let condition: any
const query: any = {
where: (where: any) => { condition = where; return query },
orderBy: () => query,
offset: () => query,
limit: () => query,
for: () => query,
then: (resolve: any, reject: any) => Promise.resolve().then(() => {
if (listRows.length) { listed.push(dialect.sqlToQuery(condition).sql); return listRows }
return rowsFor(table).filter((row) => matches(table, condition, row)).map((row) => ({ ...row }))
}).then(resolve, reject),
}
return query
} }),
insert: (table: any) => ({ values: async (data: any) => { rowsFor(table).push(data) } }),
update: (table: any) => ({ set: (data: any) => ({ where: (where: any) => ({
returning: async () => rowsFor(table).filter((row) => matches(table, where, row)).map((row) => ({ ...Object.assign(row, data) })),
}) }) }),
}
const context: any = { server: { db, log: { warn: () => undefined } }, tenantId, userId, isAdmin: true, permissions: [] }
return { context, rows: rowsFor, listed }
}
const call = async (context: any, name: string, args: Record<string, any>) => await mcpToolMap.get(`archive.entities.${name}`)!.handler(context, args) as any
for (const [key, entity] of Object.entries(archiveEntities)) {
test(`${key}: restore unarchives within the tenant and records history`, async () => {
const id = entity.uuid ? uuid : 1
const f = fixture({
[getTableName(entity.table)]: [{ id, tenant: tenantId, archived: true }],
// Restoring documents requires an internal (non-portal) profile.
auth_profiles: [{ tenant_id: tenantId, user_id: userId, customer_for_portal: null }],
})
const result = await call(f.context, "restore", { entity: key, id })
assert.deepEqual(asToolResult(result).structuredContent?.success, true)
assert.equal(result.record.archived, undefined)
const [row] = f.rows(entity.table)
assert.equal(row.archived, false)
assert.equal(row.updatedBy ?? row.updated_by, userId)
await assert.rejects(call(f.context, "restore", { entity: key, id }), /nicht archiviert/)
})
}
test("restore rejects foreign tenants, unknown entities, invalid IDs and missing write permission", async () => {
const f = fixture({ customers: [{ id: 1, tenant: 8, archived: true }], tasks: [{ id: 2, tenant: tenantId, archived: true }] })
await assert.rejects(call(f.context, "restore", { entity: "customers", id: 1 }), /nicht gefunden/)
assert.equal(f.rows(schema.customers)[0].archived, true)
await assert.rejects(call(f.context, "restore", { entity: "accounts", id: 1 }), /unbekannte Entität/)
assert.throws(() => prepareArchiveRestoreInput({ entity: "cost_centres", id: 1 }), /UUID/)
assert.throws(() => prepareArchiveRestoreInput({ entity: "tasks", id: "abc" }), /ganze Zahl/)
f.context.isAdmin = false
f.context.permissions = ["organisation.tasks.read"]
await assert.rejects(call(f.context, "restore", { entity: "tasks", id: 2 }), /Fehlende Berechtigung.*organisation\.tasks\.write/)
f.context.permissions.push("organisation.tasks.write")
await call(f.context, "restore", { entity: "tasks", id: 2 })
assert.equal(f.rows(schema.tasks)[0].archived, false)
})
test("list returns archived rows visibly and requires the read permission", async () => {
const f = fixture({}, [{ id: 1, tenant: tenantId, archived: true, name: "Alt" }])
const result = asToolResult(await call(f.context, "list", { entity: "customers", search: "Al" }))
assert.deepEqual(result.structuredContent?.rows, [{ id: 1, tenant: tenantId, name: "Alt", isArchived: true }])
assert.match(f.listed[0], /"customers"\."archived" = \$2/)
f.context.isAdmin = false
f.context.permissions = []
await assert.rejects(call(f.context, "list", { entity: "customers" }), /masterdata\.customers\.read/)
})
test("restore is advertised as a write tool", () => {
assert.equal(mcpToolMap.get("archive.entities.restore")!.readOnly, false)
})

View File

@@ -0,0 +1,219 @@
import assert from "node:assert/strict"
import test from "node:test"
import { randomUUID } from "node:crypto"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { Pool } from "pg"
import { drizzle } from "drizzle-orm/node-postgres"
import { eq, getTableColumns, getTableName } from "drizzle-orm"
import * as schema from "../db/schema"
import { mcpToolMap, mcpTools } from "../src/mcp/registry"
import { assertToolPermission } from "../src/mcp/authz"
import { asToolResult } from "../src/mcp/result"
import { eventDateTimeField, prepareEventInput, calendarTools } from "../src/mcp/tools/calendar"
import { outgoingDocumentStatus, outgoingStatusTools } from "../src/mcp/tools/outgoingStatus"
const execute = promisify(execFile)
const userId = "00000000-0000-4000-8000-000000000001"
const invoice = { id: 1, state: "Gebucht", type: "invoices", rows: [{ quantity: 1, price: 100, taxPercent: 19 }], usedAdvanceInvoices: [] }
test("registers read-only document status and permission-protected calendar tools", () => {
assert.equal(new Set(mcpTools.map(tool => tool.name)).size, mcpTools.length)
for (const tool of [...calendarTools, ...outgoingStatusTools]) {
assert.equal(mcpToolMap.get(tool.name), tool)
assert.throws(() => assertToolPermission({ isAdmin: false, permissions: [] } as any, tool), { statusCode: 403 })
assert.doesNotThrow(() => assertToolPermission({ isAdmin: false, permissions: tool.requiredPermissions } as any, tool))
}
for (const tool of outgoingStatusTools) assert.deepEqual(tool.requiredPermissions, ["accounting.outgoing_documents.read"])
for (const action of ["create", "update", "archive"]) {
assert.deepEqual(mcpToolMap.get(`organisation.events.${action}`)!.requiredPermissions, ["organisation.events.write"])
}
})
test("payment status handles drafts, partial payments, credits and advance invoices", () => {
const status = (document: any, paid: number, cancellations: any[] = [], advances: any[] = []) => outgoingDocumentStatus(document, paid, cancellations, advances)
assert.equal(status(invoice, 0).paymentStatus, "Offen")
assert.equal(status(invoice, 50).paymentStatus, "Teilbezahlt")
assert.equal(status(invoice, 119).paymentStatus, "Bezahlt")
assert.equal(status(invoice, 120).paymentStatus, "Überbezahlt")
assert.equal(status({ ...invoice, state: "Entwurf" }, 119).paymentStatus, null)
assert.equal(status({ ...invoice, type: "quotes" }, 0).paymentStatus, null)
const cancelled = status(invoice, 0, [{ id: 2, documentNumber: "ST-2" }])
assert.equal(cancelled.cancelled, true)
assert.equal(cancelled.paymentStatus, null)
assert.deepEqual(cancelled.cancellationDocuments, [{ id: 2, documentNumber: "ST-2" }])
const advance = { id: 3, rows: [{ price: 20, taxPercent: 19, advanceInvoiceData: {} }] }
assert.equal(status({ ...invoice, usedAdvanceInvoices: [3] }, 95.2, [], [advance]).paymentStatus, "Bezahlt")
assert.equal(status({ ...invoice, rows: [{ quantity: 1, price: -100, taxPercent: 19 }] }, -50).paymentStatus, "Teilbezahlt")
assert.equal(status({ ...invoice, rows: [{ quantity: 1, price: -100, taxPercent: 19 }] }, -120).paymentStatus, "Überbezahlt")
assert.equal(status({ ...invoice, rows: [] }, 0).paymentStatus, "Bezahlt")
const mixedTax = { ...invoice, rows: [{ quantity: 1, price: 0.03, taxPercent: 7 }, { quantity: 1, price: 0.02, taxPercent: 19 }] }
assert.equal(status(mixedTax, 0.05).paymentStatus, "Bezahlt", "separate tax buckets must match the outgoing-document UI")
assert.throws(() => status({ ...invoice, rows: [{ quantity: 1, price: "invalid" }] }, 0), /Positionsbetrag/)
assert.throws(() => status(invoice, NaN), /Betrag kann nicht berechnet/)
})
test("calendar validates explicit timezones, dates, editable fields and typed resources", () => {
assert.equal(eventDateTimeField.parse("2026-10-15T09:00:00+02:00", "startDate").toISOString(), "2026-10-15T07:00:00.000Z")
for (const value of ["2026-10-15", "2026-10-15T09:00:00", "2026-02-30T09:00:00Z", "2026-10-15T24:00:00Z", "2026-10-15T09:60:00Z", "bad", null]) {
assert.throws(() => eventDateTimeField.parse(value, "startDate"))
}
for (const args of [
{ id: 1 }, { id: -1, name: "Test" }, { id: 1, tenant: 2 }, { id: 1, updatedBy: userId },
{ id: 1, state: "Bezahlt" }, { id: 1, repeatInterval: "irgendwann" },
{ id: 1, profiles: [1] }, { id: 1, vehicles: ["1"] }, { id: 1, name: " " }, { id: 1, resources: [] },
]) assert.throws(() => prepareEventInput("update", args))
assert.throws(() => prepareEventInput("create", { name: "Test" }), /startDate/)
assert.deepEqual(prepareEventInput("update", { id: 1, endDate: null, profiles: [], vehicles: [1, 1] }), { id: 1, endDate: null, profiles: [], vehicles: [1] })
})
// An isolated database and local fixtures only; no production configuration is loaded.
test("calendar and outgoing status integration", { skip: !process.env.FEDEO_TEST_POSTGRES_IMAGE }, async t => {
const container = "fedeo-mcp-calendar-" + randomUUID()
const password = randomUUID()
await execute("docker", ["run", "--rm", "-d", "--name", container, "-e", `POSTGRES_PASSWORD=${password}`, "-p", "127.0.0.1::5432", process.env.FEDEO_TEST_POSTGRES_IMAGE!])
let pool: Pool | undefined
t.after(async () => { await pool?.end(); await execute("docker", ["rm", "-f", container]) })
const { stdout } = await execute("docker", ["port", container, "5432/tcp"])
pool = new Pool({ host: "127.0.0.1", port: Number(stdout.trim().split(":").at(-1)), user: "postgres", password, database: "postgres", connectionTimeoutMillis: 1000 })
for (let attempt = 0; ; attempt++) {
try { await pool.query("SELECT 1"); break } catch (error) {
if (attempt >= 80) throw error
await new Promise(resolve => setTimeout(resolve, 100))
}
}
const tables = [schema.events, schema.createddocuments, schema.statementallocations, schema.historyitems, schema.tenants,
schema.projects, schema.customers, schema.vendors, schema.authProfiles, schema.vehicles, schema.inventoryitems, schema.inventoryitemgroups]
for (const table of tables) {
const columns = Object.entries(getTableColumns(table)).map(([key, column]) => {
let suffix = ""
if (key === "id") suffix = column.getSQLType() === "uuid" ? " PRIMARY KEY DEFAULT gen_random_uuid()" : " GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY"
else if (key === "archived" || key === "quick") suffix = " DEFAULT false"
else if (key === "active") suffix = " DEFAULT true"
else if (key === "createdAt") suffix = " DEFAULT now()"
else if (["profiles", "vehicles", "inventoryitems", "inventoryitemgroups", "resources", "rows", "usedAdvanceInvoices"].includes(key)) {
suffix = column.getSQLType().endsWith("[]") ? ` DEFAULT '{}'::${column.getSQLType()}` : " DEFAULT '[]'::jsonb"
}
else if (table === schema.events && key === "state") suffix = " DEFAULT 'Final'"
else if (table === schema.events && key === "repeatInterval") suffix = " DEFAULT 'Keine Wiederholung'"
else if (table === schema.events && key === "eventtype") suffix = " DEFAULT 'Umsetzung'"
return `"${column.name}" ${column.columnType === "PgEnumColumn" ? "text" : column.getSQLType()}${suffix}`
})
await pool.query(`CREATE TABLE "${getTableName(table)}" (${columns.join(", ")})`)
}
const db = drizzle(pool) as any
const call = async (name: string, args: any = {}, tenantId = 1) => {
const context = { server: { db }, tenantId, userId, isAdmin: false,
permissions: ["organisation.events.read", "organisation.events.write", "accounting.outgoing_documents.read"] } as any
const tool = mcpToolMap.get(name)!
assert.ok(tool, name)
assertToolPermission(context, tool)
return await tool.handler(context, args) as any
}
const calendar = (name: string, args: any = {}, tenant = 1) => call(`organisation.events.${name}`, args, tenant)
const status = (name: string, args: any, tenant = 1) => call(`accounting.outgoing_documents.status.${name}`, args, tenant)
await db.insert(schema.tenants).values([{ id: 1, calendarConfig: { eventTypes: [{ label: "Büro", color: "blue" }] } }, { id: 2 }])
await db.insert(schema.projects).values([{ id: 1, tenant: 1, name: "Own" }, { id: 2, tenant: 2, name: "Foreign" }, { id: 3, tenant: 1, name: "Archived", archived: true }])
await db.insert(schema.vehicles).values([{ id: 1, tenant: 1 }, { id: 2, tenant: 2 }])
const ownProfile = randomUUID(), portalProfile = randomUUID(), group = randomUUID()
await db.insert(schema.authProfiles).values([{ id: ownProfile, tenant_id: 1 }, { id: portalProfile, tenant_id: 1, customer_for_portal: 5 }])
await db.insert(schema.inventoryitemgroups).values({ id: group, tenant: 1 })
await t.test("create, update and archive preserve metadata, history and tenant isolation", async () => {
const options = await call("organisation.calendar.options")
assert.equal(options.calendarConfig.eventTypes[0].label, "Büro")
const { event } = await calendar("create", { name: "Besprechung", startDate: "2026-10-15T09:00:00+02:00", endDate: "2026-10-15T10:00:00+02:00",
project: 1, profiles: [ownProfile], vehicles: [1], inventoryitemgroups: [group], repeatInterval: "Wöchentlich" })
assert.equal(event.state, "Final")
assert.equal(event.updatedBy, userId)
assert.equal(event.tenant, 1)
assert.equal(event.startDate.toISOString(), "2026-10-15T07:00:00.000Z")
await assert.rejects(calendar("get", { id: event.id }, 2), /nicht gefunden/)
await assert.rejects(calendar("update", { id: event.id, name: "wrong" }, 2), /nicht gefunden/)
await assert.rejects(calendar("archive", { id: event.id }, 2), /nicht gefunden/)
for (const patch of [{ project: 2 }, { project: 3 }, { profiles: [portalProfile] }, { vehicles: [2] }, { inventoryitemgroups: [randomUUID()] }]) {
await assert.rejects(calendar("update", { id: event.id, ...patch }), /nicht verfügbar/)
}
await assert.rejects(calendar("update", { id: event.id, startDate: "2026-10-15T11:00:00+02:00" }), /endDate/)
const changed = await calendar("update", { id: event.id, name: "Planung", endDate: null, profiles: [], project: null, state: "Entwurf" })
assert.equal(changed.event.endDate, null)
assert.deepEqual(changed.event.profiles, [])
assert.equal(changed.event.repeatInterval, "Wöchentlich")
assert.equal(changed.event.project, null)
const history = await db.select().from(schema.historyitems).where(eq(schema.historyitems.event, event.id))
assert.deepEqual(history.map(row => row.action), ["created", "updated"])
assert.ok(history.every(row => row.tenant === 1 && row.createdBy === userId))
await pool!.query("ALTER TABLE historyitems ADD CONSTRAINT reject_new_history CHECK (false) NOT VALID")
await assert.rejects(calendar("update", { id: event.id, name: "Must roll back" }))
assert.equal((await calendar("get", { id: event.id })).event.name, "Planung")
await pool!.query("ALTER TABLE historyitems DROP CONSTRAINT reject_new_history")
assert.deepEqual(asToolResult(await calendar("archive", { id: event.id })).structuredContent, { id: event.id, success: true, action: "archived" })
await assert.rejects(calendar("get", { id: event.id }), /archiviert/)
assert.equal((await calendar("list", {})).rows.length, 0)
})
await t.test("calendar range and resource filters handle overlaps and older recurring series", async () => {
const create = async (name: string, startDate: string, endDate: string | null, extra: any = {}, tenant = 1) =>
(await calendar("create", { name, startDate, endDate, ...extra }, tenant)).event
await create("Before", "2026-10-10T09:00:00Z", "2026-10-10T10:00:00Z")
await create("Ends at boundary", "2026-10-10T09:00:00Z", "2026-10-15T00:00:00Z")
const spanning = await create("Overlapping", "2026-10-14T23:00:00Z", "2026-10-15T01:00:00Z", { profiles: [ownProfile], vehicles: [1], state: "Entwurf", quick: true })
const inside = await create("Inside", "2026-10-15T10:00:00Z", null)
await create("End excluded", "2026-10-16T00:00:00Z", null)
const recurring = await create("Older series", "2020-10-01T09:00:00Z", "2020-10-01T10:00:00Z", { repeatInterval: "Wöchentlich" })
await create("Foreign", "2026-10-15T10:00:00Z", null, {}, 2)
const range = { from: "2026-10-15T00:00:00Z", to: "2026-10-16T00:00:00Z" }
const listed = await calendar("list", range)
assert.deepEqual(listed.rows.map(row => row.id), [inside.id, spanning.id, recurring.id])
assert.deepEqual((await calendar("list", { ...range, includeRecurring: false })).rows.map(row => row.id), [inside.id, spanning.id])
assert.deepEqual((await calendar("list", { ...range, profile: ownProfile, vehicle: 1, state: "Entwurf", quick: true })).rows.map(row => row.id), [spanning.id])
await assert.rejects(calendar("list", { from: range.to, to: range.from }), /from/)
})
await t.test("status ignores foreign/archived payments and draft cancellations and applies advances", async () => {
await db.insert(schema.createddocuments).values([
{ ...invoice, id: 100, tenant: 1, documentNumber: "RE-100", usedAdvanceInvoices: [103] },
{ ...invoice, id: 101, tenant: 2 }, { ...invoice, id: 102, tenant: 1, archived: true },
{ ...invoice, id: 103, tenant: 1, type: "advanceInvoices", rows: [{ price: 20, taxPercent: 19, advanceInvoiceData: {} }] },
{ ...invoice, id: 104, tenant: 1, type: "cancellationInvoices", state: "Entwurf", createddocument: 100 },
{ ...invoice, id: 105, tenant: 2, type: "cancellationInvoices", createddocument: 100 },
{ ...invoice, id: 106, tenant: 1, type: "cancellationInvoices", createddocument: 100, archived: true },
])
await db.insert(schema.statementallocations).values([
{ tenant: 1, createddocument: 100, amount: 95.2 }, { tenant: 1, createddocument: 100, amount: 500, archived: true },
{ tenant: 2, createddocument: 100, amount: 700 },
])
const result = (await status("get", { id: 100 })).status
assert.equal(result.paymentStatus, "Bezahlt")
assert.equal(result.amount, 95.2)
assert.equal(result.paidAmount, 95.2)
assert.equal(result.openAmount, 0)
assert.equal(result.cancelled, false)
await assert.rejects(status("get", { id: 101 }), /nicht gefunden/)
await assert.rejects(status("get", { id: 102 }), /archiviert/)
await db.insert(schema.createddocuments).values({ ...invoice, id: 107, tenant: 1, documentNumber: "ST-107", type: "cancellationInvoices", createddocument: 100 })
const cancelled = (await status("get", { id: 100 })).status
assert.equal(cancelled.paymentStatus, null)
assert.deepEqual(cancelled.cancellationDocuments, [{ id: 107, documentNumber: "ST-107" }])
assert.deepEqual((await status("list", { cancelled: true })).rows.map(row => row.id), [100])
})
await t.test("computed status filters precede pagination across several database batches", async () => {
const documents = Array.from({ length: 410 }, (_, index) => ({ ...invoice, id: 1000 + index, tenant: 1, project: 999,
createdAt: new Date("2026-10-01T00:00:00Z") }))
await db.insert(schema.createddocuments).values(documents)
await db.insert(schema.statementallocations).values(documents.filter((_, i) => i % 2 === 0).map(document => ({ tenant: 1, createddocument: document.id, amount: 119 })))
const ids: number[] = []
for (const offset of [0, 100, 200]) {
const result = await status("list", { state: "Gebucht", paymentStatus: "Bezahlt", cancelled: false, project: 999, limit: 100, offset })
assert.equal(result.rows.length, offset < 200 ? 100 : 5)
assert.equal(result.pagination.nextOffset, offset < 200 ? offset + 100 : null)
ids.push(...result.rows.map(row => row.id))
}
assert.deepEqual(ids, documents.filter((_, i) => i % 2 === 0).map(document => document.id))
assert.equal((await status("list", { project: 999, paymentStatus: "Teilbezahlt" })).rows.length, 0)
assert.equal((await status("list", { project: 999, offset: 400, limit: 100 })).rows.length, 10)
await assert.rejects(status("list", { paymentStatus: "unknown" }), /Zahlungsstatus/)
await assert.rejects(status("get", { id: 100, state: "Entwurf" }), /unbekanntes Feld/)
})
})

View File

@@ -0,0 +1,213 @@
import assert from "node:assert/strict"
import test from "node:test"
import { Readable } from "node:stream"
import Fastify from "fastify"
import { getTableColumns } from "drizzle-orm"
import { drizzle } from "drizzle-orm/pg-proxy"
import { files } from "../db/schema"
import { mcpToolMap } from "../src/mcp/registry"
import { createDocumentTools, documentTools } from "../src/mcp/tools/documents"
import { assertToolPermission } from "../src/mcp/authz"
import { asToolResult } from "../src/mcp/result"
import { decodeDocumentUpload, MCP_BODY_LIMIT, MCP_FILE_LIMIT } from "../src/mcp/documentFiles"
import { streamToBuffer } from "../src/utils/fileBuffer"
import { secrets } from "../src/utils/secrets"
import { initS3, s3 } from "../src/utils/s3"
import mcpRoutes from "../src/routes/mcp"
import authPlugin from "../src/plugins/auth"
import { createHash } from "node:crypto"
const id = "00000000-0000-4000-8000-000000000001"
const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF")
const contextFor = (db: any) => ({ server: { db }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read", "documents.write"] }) as any
const tool = (name: string) => mcpToolMap.get(`documents.${name}`)!
const profileDb = (portal: number | null = null) => drizzle(async () => ({ rows: portal ? [[portal]] : [] }))
test("document tools are registered with read/write permissions and reject insufficient permissions", () => {
assert.equal(documentTools.length, 15)
for (const entry of documentTools) {
assert.equal(mcpToolMap.get(entry.name), entry)
assert.equal(entry.inputSchema.additionalProperties, false)
const context = contextFor(profileDb())
assert.doesNotThrow(() => assertToolPermission(context, entry))
assert.throws(() => assertToolPermission({ ...context, permissions: [] }, entry), { statusCode: 403 })
assert.doesNotThrow(() => assertToolPermission({ ...context, permissions: [], isAdmin: true }, entry))
}
for (const name of ["files.upload", "inbox.file", "inbox.archive", "inbox.suggestion.refresh"]) {
assert.deepEqual(tool(name).requiredPermissions, ["documents.write"])
}
assert.deepEqual(tool("files.download").requiredPermissions, ["documents.read"])
})
test("validates actual upload bytes, strict Base64, filename and size before storage", () => {
assert.deepEqual(decodeDocumentUpload("scan.pdf", pdf.toString("base64")), { body: pdf, mimeType: "application/pdf" })
for (const value of ["", null, 42, "!!!!", "Zg==junk", "Zh==", "data:application/pdf;base64," + pdf.toString("base64")]) {
assert.throws(() => decodeDocumentUpload("scan.pdf", value), /Base64/)
}
for (const filename of ["../scan.pdf", "sub/scan.pdf", ".hidden.pdf", "x".repeat(201) + ".pdf", "scan.exe"]) {
assert.throws(() => decodeDocumentUpload(filename, pdf.toString("base64")))
}
assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.from("not a PDF").toString("base64")), /vollständige PDF/)
assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.from("%PDF-1.7 truncated").toString("base64")), /vollständige PDF/)
assert.throws(() => decodeDocumentUpload("scan.pdf", "A".repeat(Math.ceil(MCP_FILE_LIMIT / 3) * 4 + 4)), /20 MiB/)
const boundary = Buffer.alloc(MCP_FILE_LIMIT, 32)
boundary.write("%PDF-1.7\n")
boundary.write("%%EOF", boundary.length - 5)
assert.equal(decodeDocumentUpload("scan.pdf", boundary.toString("base64")).body.length, MCP_FILE_LIMIT)
assert.throws(() => decodeDocumentUpload("scan.pdf", Buffer.concat([boundary, Buffer.from("\n")]).toString("base64")), /20 MiB/)
})
test("rejects unknown system fields, invalid filters, missing IDs and portal access", async () => {
const context = contextFor(profileDb())
for (const [name, args] of [
["files.upload", { filename: "scan.pdf", contentBase64: pdf.toString("base64"), tenant: 9 }],
["files.get", {}], ["files.get", { id: "bad" }], ["files.list", { folder: "bad" }],
["files.list", { project: -1 }], ["files.list", { limit: 101 }], ["inbox.list", { search: "x".repeat(201) }],
["inbox.file", { id, folder: null, type: id, assignments: { tenant: 9 } }],
["inbox.file", { id, folder: null, type: id, assignments: { project: "12" } }],
["assignments.search", { kind: "__proto__" }], ["files.download", { id, format: "pdf" }],
] as const) await assert.rejects(tool(name).handler(context, args))
await assert.rejects(tool("inbox.count").handler(contextFor(profileDb(123)), {}), { statusCode: 403 })
await assert.rejects(tool("files.download").handler(contextFor(profileDb(123)), { id }), { statusCode: 403 })
await assert.rejects(tool("files.upload").handler(contextFor(profileDb(123)), { filename: "scan.pdf", contentBase64: pdf.toString("base64") }), { statusCode: 403 })
})
for (const name of ["inbox.list", "files.list", "folders.list", "types.list", "assignments.search"]) {
test(`${name}: retrieves 205 records with tenant filters and stable pagination`, async () => {
const db = drizzle(async (sql, params) => {
if (sql.includes('from "auth_profiles"')) return { rows: [] }
assert.match(sql, /"tenant(?:_id)?" = \$\d+/)
assert.ok(params.includes(7))
assert.match(sql, /"archived" = \$\d+/)
assert.match(sql, /order by .*"id"/)
if (name === "inbox.list") {
assert.match(sql, /"filed_at" is null/)
assert.match(sql, /"files"\."tenant" =/)
}
if (name === "files.list") {
assert.match(sql, /"folder" is null/)
assert.match(sql, /"createddocument" =/)
assert.ok(params.includes(42))
}
const limit = Number(params[Number(sql.match(/limit \$(\d+)/)![1]) - 1])
const offset = Number(params[Number(sql.match(/offset \$(\d+)/)?.[1]) - 1] || 0)
assert.equal(limit, 101)
const selected = sql.slice(7, sql.indexOf(" from ")).split(", ")
const idIndex = selected.findIndex(column => /"id"$/.test(column))
return { rows: Array.from({ length: Math.min(limit, 205 - offset) }, (_, i) => {
const values = selected.map(() => null as any)
values[idIndex] = String(i + offset + 1)
return values
}) }
})
const ids: string[] = []
for (const offset of [0, 100, 200]) {
const args = { limit: 100, offset, ...(name === "assignments.search" ? { kind: "project" } : {}),
...(name === "files.list" ? { folder: null, createddocument: 42 } : {}) }
const result: any = await tool(name).handler(contextFor(db), args)
ids.push(...result.rows.map((row: any) => String(row.id)))
assert.equal(result.pagination.hasMore, offset < 200)
assert.equal(result.pagination.nextOffset, offset < 200 ? offset + 100 : null)
}
assert.deepEqual(ids, Array.from({ length: 205 }, (_, i) => String(i + 1)))
})
}
test("archive confirmation survives MCP's archived-record filter and upload forwards the active user", async () => {
const calls: any[] = []
const tools = createDocumentTools({ inbox: () => ({
archiveDocument: async (...args: any[]) => { calls.push(args); return { id, archived: true } },
upload: async (...args: any[]) => { calls.push(args); return { id, created: true, checksum: "hash" } },
}) as any })
const context = contextFor(profileDb())
const archived = await tools.find(t => t.name.endsWith("inbox.archive"))!.handler(context, { id })
assert.deepEqual(asToolResult(archived).structuredContent, { id, success: true, action: "archived" })
assert.deepEqual(calls[0], [7, id, id])
await tools.find(t => t.name.endsWith("files.upload"))!.handler(context, { filename: "scan.pdf", contentBase64: pdf.toString("base64") })
assert.deepEqual(calls[1], [7, id, "scan.pdf", pdf])
})
test("bounded downloads stop a stream even when persisted size is missing or incorrect", async () => {
let closed = false
async function* chunks() { try { yield Buffer.alloc(4); yield Buffer.alloc(4); yield Buffer.alloc(4) } finally { closed = true } }
await assert.rejects(streamToBuffer(chunks(), 5), /zu groß/)
assert.equal(closed, true)
assert.deepEqual(await streamToBuffer(Readable.from([pdf]), pdf.length), pdf)
})
test("HTTP MCP accepts uploads above 1 MiB and streams authorized PDFs without exposing storage paths", async t => {
const apiBase = secrets.API_BASE_URL
secrets.API_BASE_URL = "https://app.fedeo.test/backend/"
t.after(() => { secrets.API_BASE_URL = apiBase })
const row = { id, tenant: 7, name: "scan.pdf", mimeType: "application/pdf", size: pdf.length, archived: false, path: `7/filesbyid/${id}/scan.pdf` }
let visible = true
let portal = false
let admin = true
const bearer = "fedeo_mcp_test_documents_token"
const headers = { authorization: `Bearer ${bearer}` }
const db = drizzle(async (sql, params) => {
if (sql.includes('from "m2m_api_keys"')) {
assert.ok(params.includes(createHash("sha256").update(bearer).digest("hex")))
return { rows: [[id, 7, id, true, null, "test@example.invalid", admin]] }
}
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
if (sql.includes('from "auth_users"')) return { rows: [[admin]] }
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
if (sql.includes('from "auth_user_roles"')) return { rows: [[id]] }
if (sql.includes('from "auth_role_permissions"')) return { rows: [] }
if (sql.includes('from "auth_profiles"')) return { rows: portal ? [[123]] : [] }
assert.match(sql, /from "files"/)
assert.match(sql, /"tenant" =/)
assert.match(sql, /"archived" =/)
assert.ok(params.includes(7))
const selected = sql.slice(7, sql.indexOf(" from ")).split(", ")
const columns = getTableColumns(files)
return { rows: visible ? [selected.map(column => {
const name = column.match(/"([^"]+)"$/)![1]
const key = Object.keys(columns).find(key => columns[key].name === name)!
return row[key] ?? null
})] : [] }
})
const app = Fastify()
t.after(() => app.close())
app.decorate("db", db as any)
await app.register(authPlugin)
await app.register(mcpRoutes, { prefix: "/api" })
await initS3()
t.mock.method(s3, "send", async command => {
assert.equal(command.input.Key, row.path)
return { Body: Readable.from([pdf]) }
})
const call = (name: string, args: any) => app.inject({ method: "POST", url: "/api/mcp", headers,
payload: { jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } } })
const download = await call("documents.files.download", { id })
assert.equal(download.statusCode, 200)
assert.equal(download.json().result.structuredContent.authentication, "Bearer")
assert.equal(download.json().result.structuredContent.url, `https://app.fedeo.test/backend/api/mcp/files/${id}/content`)
assert.ok(!download.body.includes("filesbyid"))
const url = `/api/mcp/files/${id}/content`
assert.equal((await app.inject(url)).statusCode, 401)
const binary = await app.inject({ url, headers })
assert.equal(binary.statusCode, 200, binary.body)
assert.deepEqual(binary.rawPayload, pdf)
assert.equal(binary.headers["content-type"], "application/pdf")
assert.match(binary.headers["content-disposition"] as string, /attachment; filename="scan.pdf"/)
assert.equal(binary.headers["cache-control"], "no-store")
const base64 = await call("documents.files.download", { id, format: "base64" })
assert.deepEqual(Buffer.from(base64.json().result.structuredContent.contentBase64, "base64"), pdf)
admin = false
assert.equal((await app.inject({ url, headers })).statusCode, 403)
assert.equal((await call("documents.files.upload", { filename: "scan.pdf", contentBase64: pdf.toString("base64") })).json().error.code, -32003)
admin = true
portal = true
assert.equal((await app.inject({ url, headers })).statusCode, 403)
portal = false
visible = false
assert.equal((await app.inject({ url, headers })).statusCode, 404)
assert.equal((await call("documents.files.download", { id })).json().result.isError, true)
const largeInvalidUpload = await call("documents.files.upload", { filename: "scan.pdf", contentBase64: "A".repeat(2 * 1024 * 1024) })
assert.equal(largeInvalidUpload.statusCode, 200, "the request must reach file validation, not Fastify's default 1 MiB limit")
assert.equal(largeInvalidUpload.json().result.isError, true)
const oversized = await app.inject({ method: "POST", url: "/api/mcp", headers: { "content-type": "application/json", "content-length": String(MCP_BODY_LIMIT + 1) }, payload: "{}" })
assert.equal(oversized.statusCode, 413)
})

View File

@@ -0,0 +1,106 @@
import assert from "node:assert/strict"
import test from "node:test"
import { createHash } from "node:crypto"
import Fastify from "fastify"
import { drizzle } from "drizzle-orm/pg-proxy"
import mcpRoutes from "../src/routes/mcp"
import authPlugin from "../src/plugins/auth"
import { mcpToolMap } from "../src/mcp/registry"
import { parseUploadQuery, storeMcpFileUpload } from "../src/mcp/fileUploads"
import { MCP_FILE_LIMIT } from "../src/mcp/documentFiles"
import { secrets } from "../src/utils/secrets"
const id = "00000000-0000-4000-8000-000000000001"
const pdf = Buffer.from("%PDF-1.7\nMCP fixture\n%%EOF")
const bearer = "fedeo_mcp_test_upload_token"
const headers = { authorization: `Bearer ${bearer}` }
async function app(t: any) {
const uploads: any[] = []
const db = drizzle(async (sql, params) => {
if (sql.includes('from "m2m_api_keys"')) {
assert.ok(params.includes(createHash("sha256").update(bearer).digest("hex")))
return { rows: [[id, 7, id, true, null, "test@example.invalid", true]] }
}
if (sql.startsWith('update "m2m_api_keys"')) return { rows: [] }
if (sql.includes('from "auth_users"')) return { rows: [[true]] }
if (sql.includes('from "tenants"')) return { rows: [[null, null]] }
return { rows: [] }
})
const server = Fastify()
t.after(() => server.close())
server.decorate("db", db as any)
await server.register(authPlugin)
await server.register(mcpRoutes, {
prefix: "/api",
storeUpload: async (context: any, upload: any) => { uploads.push({ tenantId: context.tenantId, ...upload }); return { stored: true } },
})
return { server, uploads }
}
const multipartBody = (filename: string, content: Buffer, field = "file") => {
const boundary = "----fedeo-test"
const payload = Buffer.concat([
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${field}"; filename="${filename}"\r\nContent-Type: application/pdf\r\n\r\n`),
content,
Buffer.from(`\r\n--${boundary}--\r\n`),
])
return { payload, headers: { ...headers, "content-type": `multipart/form-data; boundary=${boundary}` } }
}
test("binary endpoint accepts multipart and raw uploads without Base64", async (t) => {
const { server, uploads } = await app(t)
const multipart = multipartBody("rechnung.pdf", pdf)
const first = await server.inject({ method: "POST", url: "/api/mcp/files?target=incoming_invoice&invoiceId=5&prepare=false", ...multipart })
assert.equal(first.statusCode, 200, first.body)
assert.deepEqual(first.json(), { stored: true })
assert.equal(uploads[0].tenantId, 7)
assert.equal(uploads[0].target, "incoming_invoice")
assert.equal(uploads[0].filename, "rechnung.pdf")
assert.equal(uploads[0].invoiceId, 5)
assert.equal(uploads[0].prepare, false)
assert.deepEqual(uploads[0].body, pdf)
const raw = await server.inject({ method: "POST", url: "/api/mcp/files?filename=scan.pdf", headers: { ...headers, "content-type": "application/pdf" }, payload: pdf })
assert.equal(raw.statusCode, 200, raw.body)
assert.equal(uploads[1].target, "inbox")
assert.equal(uploads[1].mimeType, "application/pdf")
assert.deepEqual(uploads[1].body, pdf)
})
test("binary endpoint rejects missing auth, filenames, wrong fields, types and oversized files", async (t) => {
const { server, uploads } = await app(t)
const raw = { "content-type": "application/pdf" }
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: raw, payload: pdf })).statusCode, 401)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", ...multipartBody("a.pdf", pdf, "other") })).statusCode, 400)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, "content-type": "text/html" }, payload: "<p>" })).statusCode, 415)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf&target=wiki", headers: { ...headers, ...raw }, payload: pdf })).statusCode, 400)
const tooLarge = Buffer.alloc(MCP_FILE_LIMIT + 1)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files?filename=a.pdf", headers: { ...headers, ...raw }, payload: tooLarge })).statusCode, 413)
assert.equal((await server.inject({ method: "POST", url: "/api/mcp/files", ...multipartBody("a.pdf", tooLarge) })).statusCode, 413)
assert.equal(uploads.length, 0)
})
test("query parsing keeps invoice options to the incoming invoice target", () => {
assert.deepEqual(parseUploadQuery({}), { target: "inbox", invoiceId: null, prepare: true, filename: null })
assert.throws(() => parseUploadQuery({ invoiceId: "3" }), /nur für target=incoming_invoice/)
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", invoiceId: "x" }), /positive ganze Zahl/)
assert.throws(() => parseUploadQuery({ target: "incoming_invoice", prepare: "ja" }), /true oder false/)
})
test("stored uploads require the permission of the chosen target", async () => {
const context: any = { server: { db: null }, tenantId: 7, userId: id, isAdmin: false, permissions: ["documents.read"] }
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: pdf }), /documents\.write/)
await assert.rejects(storeMcpFileUpload(context, { target: "incoming_invoice", filename: "a.pdf", body: pdf }), /accounting\.incoming_invoices\.write/)
await assert.rejects(storeMcpFileUpload(context, { target: "inbox", filename: "a.pdf", body: Buffer.alloc(0) }), /leer/)
})
test("upload_url tool returns the absolute endpoint with examples", async (t) => {
const apiBase = secrets.API_BASE_URL
secrets.API_BASE_URL = "https://app.fedeo.test/backend/"
t.after(() => { secrets.API_BASE_URL = apiBase })
const result: any = await mcpToolMap.get("documents.files.upload_url")!.handler({} as any, {})
assert.equal(result.url, "https://app.fedeo.test/backend/api/mcp/files")
assert.match(result.examples[0], /-F "file=@rechnung\.pdf"/)
})

View File

@@ -0,0 +1,41 @@
import assert from "node:assert/strict"
import test from "node:test"
import { mcpToolMap } from "../src/mcp/registry"
const fileA = "00000000-0000-4000-8000-00000000000a"
const fileB = "00000000-0000-4000-8000-00000000000b"
const tool = mcpToolMap.get("accounting.incoming_invoices.prepare")!
function context(result: any, invoices: any[] = []) {
const calls: any[] = []
const db: any = {
select: () => ({ from: () => ({ where: async () => invoices }) }),
}
const services = { prepareIncomingInvoices: { run: async (tenantId: number, options: any) => { calls.push({ tenantId, options }); return result } } }
return { calls, context: { server: { db, services }, tenantId: 7, userId: "user", isAdmin: true, permissions: [] } as any }
}
test("prepare runs for the active tenant and returns created drafts with validation", async () => {
const invoice = { id: 11, tenant: 7, state: "Entwurf", archived: false }
const { calls, context: ctx } = context({ filesFound: 1, created: [{ fileId: fileA, invoiceId: 11, source: "gpt" }], skipped: [] }, [invoice])
const result: any = await tool.handler(ctx, {})
assert.deepEqual(calls, [{ tenantId: 7, options: { fileIds: undefined } }])
assert.equal(result.created[0].invoice.id, 11)
assert.ok(result.created[0].validation)
assert.deepEqual(result.skipped, [])
})
test("prepare limits to given files and reports files it could not handle", async () => {
const { calls, context: ctx } = context({ filesFound: 1, created: [], skipped: [{ fileId: fileA, reason: "Keine Belegdaten erkannt" }] })
const result: any = await tool.handler(ctx, { fileIds: [fileA, fileB.toUpperCase(), fileB] })
assert.deepEqual(calls[0].options.fileIds, [fileA, fileB])
assert.deepEqual(result.skipped.map((entry: any) => entry.fileId), [fileA, fileB])
})
test("prepare validates input and surfaces tenant configuration errors", async () => {
const { context: ctx } = context({ filesFound: 0, created: [], skipped: [], error: "Dateityp für Eingangsrechnungen fehlt" })
await assert.rejects(tool.handler(ctx, { fileIds: [] }), /1 bis 50/)
await assert.rejects(tool.handler(ctx, { fileIds: ["abc"] }), /UUID/)
await assert.rejects(tool.handler(ctx, {}), /Dateityp für Eingangsrechnungen fehlt/)
assert.deepEqual(tool.requiredPermissions, ["accounting.incoming_invoices.write"])
})

View File

@@ -10,7 +10,9 @@ const tables = Object.values(schema).filter((value: any) => {
try { return typeof getTableName(value) === "string" } catch { return false } try { return typeof getTableName(value) === "string" } catch { return false }
}) })
for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset)) { // Document/status tools load related records; their dedicated tests cover filtered pagination.
for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any)?.offset && !tool.name.startsWith("documents.")
&& tool.name !== "accounting.outgoing_documents.status.list")) {
test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => { test(`${tool.name}: retrieves 205 records across stable, bounded pages`, async () => {
const db = drizzle(async (sql, params) => { const db = drizzle(async (sql, params) => {
if (sql.includes('from "tenants"')) return { rows: [["skr03"]] } if (sql.includes('from "tenants"')) return { rows: [["skr03"]] }
@@ -38,11 +40,12 @@ for (const tool of mcpTools.filter((tool) => (tool.inputSchema.properties as any
return values return values
}) } }) }
}, { schema }) }, { schema })
const context = { server: { db }, tenantId: 7, userId: "test" } as any const context = { server: { db }, tenantId: 7, userId: "test", isAdmin: true } as any
const properties = tool.inputSchema.properties as any
const ids: unknown[] = [] const ids: unknown[] = []
let offset = 0 let offset = 0
for (let page = 0; page < 3; page++) { for (let page = 0; page < 3; page++) {
const result: any = await tool.handler(context, { id: 1, limit: 100, offset }) const result: any = await tool.handler(context, { ...(properties.id ? { id: 1 } : {}), ...(properties.entity ? { entity: properties.entity.enum[0] } : {}), limit: 100, offset })
assert.equal(result.rows.length, page < 2 ? 100 : 5) assert.equal(result.rows.length, page < 2 ? 100 : 5)
assert.equal(result.pagination.hasMore, page < 2) assert.equal(result.pagination.hasMore, page < 2)
assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null) assert.equal(result.pagination.nextOffset, page < 2 ? offset + 100 : null)

View File

@@ -0,0 +1,88 @@
import assert from "node:assert/strict"
import { Readable } from "node:stream"
import test from "node:test"
import { PDFDocument, PDFPage } from "pdf-lib"
import { createInvoicePDF } from "../src/utils/pdf"
import { initS3, s3 } from "../src/utils/s3"
const createDocumentData = (type: string) => {
const item = (pos: string) => ({
mode: "normal",
pos,
text: `Artikel ${pos}`,
quantity: "1",
unit: "Stk",
taxPercent: 19,
price: "25,00 €",
rowAmount: "25,00 €",
})
return {
type,
adressLine: "Musterfirma, Musterweg 1, 12345 Musterstadt",
recipient: ["Musterkunde"],
info: [],
title: type === "packingSlips" ? "Packschein" : type === "deliveryNotes" ? "Lieferschein" : "Rechnung",
description: "Abschnitte mit Seitenumbruch",
startText: "Ihre Artikel:",
endText: "Vielen Dank.",
rows: [
{ mode: "title", pos: "1", text: "Abschnitt A" },
item("1.1"),
{ mode: "title", pos: "2", text: "Abschnitt B" },
item("2.1"),
{ mode: "pagebreak" },
{ mode: "title", pos: "3", text: "Abschnitt C" },
item("3.1"),
],
total: {
titleSums: {
"1 - Abschnitt A": "25,00 €",
"2 - Abschnitt B": "25,00 €",
"3 - Abschnitt C": "25,00 €",
},
titleSumsTransfer: {
"2 - Abschnitt B": "25,00 €",
"3 - Abschnitt C": "50,00 €",
},
},
totalArray: [{ label: "Nettobetrag", content: "75,00 €" }],
}
}
for (const type of ["packingSlips", "deliveryNotes", "invoices"]) {
test(`${type}: renders transfers only for priced documents, including before page breaks`, async t => {
const background = await PDFDocument.create()
background.addPage().drawText("Musterfirma", { x: 60, y: 800, size: 12 })
const backgroundBytes = await background.save()
await initS3()
t.mock.method(s3, "send", async () => ({ Body: Readable.from([backgroundBytes]) }))
const drawText = t.mock.method(PDFPage.prototype, "drawText")
const result = await createInvoicePDF(null, "base64", createDocumentData(type), "test-letterhead.pdf")
assert.equal(result?.mimeType, "application/pdf")
const pdf = await PDFDocument.load(Buffer.from(result!.base64, "base64"))
assert.equal(pdf.getPageCount(), 2)
const texts = drawText.mock.calls.map(call => call.arguments[0])
for (const text of ["Abschnitt A", "Abschnitt B", "Abschnitt C", "Artikel 1.1", "Artikel 2.1", "Artikel 3.1"]) {
assert.ok(texts.includes(text), `Missing document content: ${text}`)
}
assert.equal(texts.filter(text => text === "1 Stk").length, 3)
assert.equal(texts.filter(text => text === "Check").length, type === "packingSlips" ? 2 : 0)
const transfers = texts.filter(text => text.startsWith("Übertrag:"))
if (type === "invoices") {
assert.deepEqual(transfers, ["Übertrag: 25,00 €", "Übertrag: 50,00 €"])
assert.ok(texts.includes("75,00 €"))
assert.equal(texts.filter(text => text === "Einheitspreis").length, 2)
} else {
assert.deepEqual(transfers, [])
assert.ok(texts.every(text => !text.includes("€")), "Delivery documents must not display monetary amounts")
for (const label of ["Steuer", "Einheitspreis", "Gesamt", "Nettobetrag"]) {
assert.ok(!texts.includes(label), `Unexpected price label: ${label}`)
}
}
})
}

View File

@@ -18,6 +18,8 @@ import inboxRoutes from "../src/routes/documentInbox"
import corsPlugin from "../src/plugins/cors" import corsPlugin from "../src/plugins/cors"
import { createScannerInboxService, SCANNER_UPLOAD_LIMIT, validateScannerFile, type ScannerInboxService } from "../src/modules/scanner-inbox.service" import { createScannerInboxService, SCANNER_UPLOAD_LIMIT, validateScannerFile, type ScannerInboxService } from "../src/modules/scanner-inbox.service"
import type { FilingSuggestionResult } from "../src/modules/scanner-inbox-suggestions" import type { FilingSuggestionResult } from "../src/modules/scanner-inbox-suggestions"
import { createDocumentTools } from "../src/mcp/tools/documents"
import { assertToolPermission } from "../src/mcp/authz"
const execute = promisify(execFile) const execute = promisify(execFile)
const basic = (username = "scanner", password = "secret") => ({ authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` }) const basic = (username = "scanner", password = "secret") => ({ authorization: `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}` })
@@ -611,4 +613,64 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201) assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201)
assert.equal((await service.list(1, 50, 0, "")).total, 2) assert.equal((await service.list(1, 50, 0, "")).total, 2)
}) })
await t.test("MCP uploads, downloads and filing use the same tenant-scoped document lifecycle", async () => {
const tools = createDocumentTools({ inbox: () => service, readFile: async key => {
assert.ok(stored.has(key))
return stored.get(key)!
} })
const call = async (name: string, args: any = {}, tenantId = 1) => {
const tool = tools.find(tool => tool.name === `documents.${name}`)!
const context = { server: { db }, userId, tenantId, isAdmin: false, permissions: ["documents.read", "documents.write"] } as any
assertToolPermission(context, tool)
return await tool.handler(context, args) as any
}
const upload = (filename: string, body = pdf, tenant = 1) => call("files.upload", { filename, contentBase64: body.toString("base64") }, tenant)
await service.disable(1)
const created = await upload("mcp.pdf")
assert.equal(created.created, true, "MCP uploads do not require scanner credentials")
assert.equal((await upload("mcp.pdf")).id, created.id)
assert.equal((await upload("mcp.pdf")).created, false)
await assert.rejects(upload("mcp.pdf", samplePdf), { statusCode: 409 })
assert.notEqual((await upload("mcp.pdf", pdf, 2)).id, created.id)
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [created.id])).rows[0]
assert.equal(row.created_by, userId)
assert.deepEqual(stored.get(row.path), pdf)
assert.equal(row.extracted_text, null)
const list = await call("inbox.list", { search: "mcp.pdf", limit: 1 })
assert.equal(list.rows[0].id, created.id)
assert.equal(list.rows[0].suggestionStatus, "pending")
assert.equal((await call("inbox.suggestion.get", { id: created.id })).status, "pending")
const downloaded = await call("files.download", { id: created.id, format: "base64" })
assert.deepEqual(Buffer.from(downloaded.contentBase64, "base64"), pdf)
await assert.rejects(call("files.download", { id: created.id }, 2), { statusCode: 404 })
await assert.rejects(call("inbox.archive", { id: created.id }, 2), { statusCode: 404 })
await assert.rejects(call("inbox.file", { id: created.id, folder: foreignFolder, type: targetType }), { statusCode: 400 })
await assert.rejects(call("inbox.file", { id: created.id, folder: targetFolder, type: alternativeType }), { statusCode: 400 })
await pool.query("INSERT INTO projects (id, tenant, name) VALUES (901, 1, 'MCP project'), (902, 2, 'Foreign MCP project')")
await assert.rejects(call("inbox.file", { id: created.id, folder: targetFolder, type: targetType, assignments: { project: 902 } }), { statusCode: 400 })
const filed = await call("inbox.file", { id: created.id, folder: targetFolder, type: targetType, assignments: { project: 901 } })
assert.equal(filed.id, created.id)
assert.equal((await call("inbox.list", { search: "mcp.pdf" })).rows.length, 0)
assert.equal((await call("files.list", { project: 901 })).rows[0].id, created.id)
assert.equal((await call("files.get", { id: created.id })).file.path, undefined)
await assert.rejects(call("inbox.archive", { id: created.id }), { statusCode: 409 })
const archived = await upload("mcp-archive.pdf")
await call("inbox.archive", { id: archived.id })
await assert.rejects(call("files.download", { id: archived.id }), { statusCode: 404 })
assert.equal((await call("files.list", { search: "mcp-archive.pdf" })).rows.length, 0)
const before = stored.size
storageFailure = true
await assert.rejects(upload("mcp-storage-error.pdf"), /Simulated object store failure/)
storageFailure = false
await assert.rejects(upload("reject.pdf"))
assert.equal(stored.size, before)
assert.equal((await pool.query("SELECT count(*) FROM files WHERE name IN ('mcp-storage-error.pdf', 'reject.pdf')")).rows[0].count, "0")
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
await assert.rejects(upload("mcp-maintenance.pdf"), { 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])
await assert.rejects(upload("mcp-portal.pdf"), { statusCode: 403 })
await assert.rejects(call("files.download", { id: created.id }), { statusCode: 403 })
await pool.query("DELETE FROM auth_profiles")
})
}) })

View File

@@ -0,0 +1,233 @@
import assert from "node:assert/strict"
import test from "node:test"
import Fastify from "fastify"
import cookie from "@fastify/cookie"
import bcrypt from "bcrypt"
import jwt from "jsonwebtoken"
import { drizzle } from "drizzle-orm/pg-proxy"
import { Pool } from "pg"
import { createHash } from "node:crypto"
import authRoutes from "../src/routes/auth/auth"
import userRoutes from "../src/routes/auth/user"
import authPlugin from "../src/plugins/auth"
import m2mAuthPlugin from "../src/plugins/auth.m2m"
import tenantRoutes from "../src/routes/tenant"
import internalTenantRoutes from "../src/routes/internal/tenant"
import profileRoutes from "../src/routes/profiles"
import resourceRoutes from "../src/routes/resources/main"
import { secrets } from "../src/utils/secrets"
const adminId = "00000000-0000-4000-8000-000000000001"
const userId = "00000000-0000-4000-8000-000000000002"
const profileId = "00000000-0000-4000-8000-000000000003"
const otherProfileId = "00000000-0000-4000-8000-000000000004"
const passwordHash = bcrypt.hashSync("archive-test-password", 4)
type Row = Record<string, any>
// Execute the generated SQL against a small fixture, including its archive filters.
// This keeps the HTTP/authentication tests independent of a configured production DB.
function matches(where: string, params: any[], row: Row) {
for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" = \$(\d+)/g)) {
if (String(row[match[1]]) !== String(params[Number(match[2]) - 1])) return false
}
for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" is null/g)) {
if (row[match[1]] != null) return false
}
for (const match of where.matchAll(/(?:"\w+"\.)?"(\w+)" in \(([^)]+)\)/g)) {
const values = [...match[2].matchAll(/\$(\d+)/g)].map(value => params[Number(value[1]) - 1])
if (!values.includes(row[match[1]])) return false
}
return true
}
function selected(columns: string, row: Row) {
return columns.split(", ").map(column => {
const name = [...column.matchAll(/"([^"]+)"/g)].at(-1)?.[1]
return row[name!] ?? null
})
}
async function fixture(t: any) {
const data: Record<string, Row[]> = {
auth_users: [
{ id: adminId, email: "admin@example.test", is_admin: true, archived_at: null },
{ id: userId, email: "former@example.test", password_hash: passwordHash, is_admin: false, archived_at: null },
],
auth_profiles: [
{ id: profileId, user_id: userId, tenant_id: 7, active: true, archived_at: null, first_name: "Former", last_name: "User", full_name: "Former User", token_id: "rfid", calendar_subscription_token: "calendar" },
{ id: otherProfileId, user_id: userId, tenant_id: 8, active: false, archived_at: null },
{ id: adminId, user_id: adminId, tenant_id: 7, active: true, archived_at: null },
],
auth_tenant_users: [{ tenant_id: 7, user_id: userId }, { tenant_id: 8, user_id: userId }, { tenant_id: 7, user_id: adminId }],
auth_refresh_tokens: [{ id: userId, user_id: userId, tenant_id: 7, token_hash: "old", revoked_at: null }],
m2m_api_keys: [{ id: userId, user_id: userId, tenant_id: 7, active: true, key_hash: createHash("sha256").update("fedeo_mcp_archive_test").digest("hex") }],
tenants: [{ id: 7, name: "First" }, { id: 8, name: "Second" }],
}
const queries: string[] = []
let inTransaction = false
const db = drizzle(async (sql, params) => {
queries.push(sql)
if (sql.startsWith("select ")) {
const table = sql.match(/from "([^"]+)"/)![1]
const source = table === "m2m_api_keys"
? data[table].map(row => ({ ...row, ...data.auth_users.find(user => user.id === row.user_id) }))
: data[table] || []
const rows = source.filter(row => matches(sql.split(" where ")[1] || "", params, row))
if (sql.startsWith("select count(")) return { rows: [[rows.length]] }
return { rows: rows.map(row => selected(sql.slice(7, sql.indexOf(" from ")), row)) }
}
if (sql.startsWith("update ")) {
const table = sql.match(/update "([^"]+)"/)![1]
const rows = (data[table] || []).filter(row => matches(sql.split(" where ")[1] || "", params, row))
if (sql.includes('"archived_at" =')) assert.ok(inTransaction, "Archiving must be atomic")
const assignments = sql.slice(sql.indexOf(" set "), sql.indexOf(" where "))
for (const row of rows) {
for (const match of assignments.matchAll(/"(\w+)" = \$(\d+)/g)) row[match[1]] = params[Number(match[2]) - 1]
}
return { rows: sql.includes(" returning ") ? rows.map(row => selected(sql.split(" returning ")[1], row)) : [] }
}
throw new Error(`Unexpected mutation: ${sql}`)
})
;(db as any).transaction = async (fn: any) => {
inTransaction = true
try { return await fn(db) } finally { inTransaction = false }
}
const app = Fastify()
app.decorate("db", db as any)
app.addHook("preHandler", async (req: any) => {
req.user = { user_id: req.headers["x-test-user"] || adminId, tenant_id: 7 }
req.queryConfig = { pagination: { limit: 100, offset: 0 }, filters: {}, sort: [] }
})
// adminRoutes imports the export utility, whose legacy DB module probes on import.
const queryMock = t.mock.method(Pool.prototype, "query", async () => ({ rows: [{ now: "test" }] }))
const { default: adminRoutes } = await import("../src/routes/admin")
queryMock.mock.restore()
await app.register(adminRoutes)
await app.register(tenantRoutes)
await app.register(profileRoutes)
await app.register(userRoutes)
await app.register(resourceRoutes)
await app.register(internalTenantRoutes, { prefix: "/internal" })
t.after(() => app.close())
const archive = (id = userId, caller = adminId) => app.inject({ method: "POST", url: `/admin/users/${id}/archive`, headers: { "x-test-user": caller } })
return { app, db, data, queries, archive }
}
test("archiving preserves history, archives every tenant profile and revokes credentials atomically", async t => {
const f = await fixture(t)
const memberships = structuredClone(f.data.auth_tenant_users)
const response = await f.archive()
assert.equal(response.statusCode, 200, response.body)
const archivedAt = response.json().user.archived_at
assert.ok(archivedAt)
for (const profile of f.data.auth_profiles.filter(profile => profile.user_id === userId)) {
assert.equal(profile.active, false)
assert.equal(profile.archived_at, archivedAt)
assert.equal(profile.token_id, null)
assert.equal(profile.calendar_subscription_token, null)
}
assert.equal(f.data.auth_profiles[2].active, true)
assert.equal(f.data.auth_users[0].archived_at, null)
assert.equal(f.data.auth_refresh_tokens[0].revoked_at, archivedAt)
assert.equal(f.data.m2m_api_keys[0].active, false)
assert.deepEqual(f.data.auth_tenant_users, memberships)
assert.equal((await f.archive()).json().user.archived_at, archivedAt)
assert.ok(f.queries.some(sql => sql.endsWith("for update")))
})
test("only an active system admin can archive another existing user", async t => {
const f = await fixture(t)
assert.equal((await f.archive(adminId, userId)).statusCode, 403)
assert.equal((await f.archive(adminId)).statusCode, 409)
assert.equal((await f.archive(profileId)).statusCode, 404)
assert.equal((await f.archive("invalid-id")).statusCode, 400)
f.data.auth_users[0].archived_at = new Date().toISOString()
assert.equal((await f.archive()).statusCode, 403)
assert.equal(f.data.auth_users[1].archived_at, null)
})
test("archived users disappear from active administration and tenant lists but retain a readable archive", async t => {
const f = await fixture(t)
await f.archive()
const overview = (await f.app.inject("/admin/overview")).json()
assert.deepEqual(overview.users.map((row: Row) => row.id), [adminId])
assert.equal(overview.tenants.find((row: Row) => row.id === 7).user_count, 1)
const archived = (await f.app.inject("/admin/overview?includeArchived=true")).json().users.find((row: Row) => row.id === userId)
assert.ok(archived.archived_at)
assert.equal(archived.profiles.length, 2)
for (const url of ["/tenant/users", "/internal/tenant/users"]) {
const response = await f.app.inject(url)
assert.equal(response.statusCode, 200, response.body)
assert.deepEqual(response.json().users.map((row: Row) => row.id), [adminId])
}
for (const url of ["/tenant/profiles", "/internal/tenant/7/profiles", "/resource/profiles", "/resource/authProfile", "/resource/profiles/paginated"]) {
const response = await f.app.inject(url)
assert.equal(response.statusCode, 200, response.body)
const body = response.json()
assert.deepEqual((body.data || body).map((row: Row) => row.id), [adminId], url)
}
const historic = (await f.app.inject("/tenant/profiles?includeArchived=true")).json().data
assert.equal(historic.find((row: Row) => row.id === profileId).full_name, "Former User")
const detail = await f.app.inject(`/resource/profiles/${profileId}`)
assert.equal(detail.statusCode, 200, detail.body)
assert.equal(detail.json().full_name, "Former User")
})
test("archived accounts and profiles cannot be edited, reassigned or reactivated", async t => {
const f = await fixture(t)
await f.archive()
for (const [method, url, payload, status] of [
["PUT", `/admin/users/${userId}`, { archived_at: null, is_admin: true }, 409],
["PUT", `/admin/users/${userId}/access`, { tenant_ids: [9] }, 409],
["POST", `/admin/users/${userId}/reset-password`, {}, 409],
["POST", "/admin/add-user-to-tenant", { user_id: userId, tenant_id: 9 }, 404],
["PUT", `/user/${userId}/profile`, { data: { active: true, archived_at: null, user_id: adminId } }, 404],
["PUT", `/profiles/${profileId}`, { active: true, archived_at: null }, 404],
["PUT", `/resource/profiles/${profileId}`, { active: true, archived_at: null, user_id: adminId }, 409],
["POST", `/profiles/${profileId}/calendar-subscription-token`, {}, 404],
["POST", "/resource/profiles", { user_id: userId, first_name: "New", last_name: "Profile" }, 409],
] as const) {
const response = await f.app.inject({ method, url, payload })
assert.equal(response.statusCode, status, `${url}: ${response.body}`)
}
assert.equal(f.data.auth_profiles[0].active, false)
assert.equal(f.data.auth_profiles[0].user_id, userId)
})
test("archiving blocks password login, refresh, existing JWTs and legacy API keys", async t => {
const f = await fixture(t)
const oldSecret = secrets.JWT_SECRET, oldM2mKey = secrets.M2M_API_KEY
secrets.JWT_SECRET = "archive-test-secret"
secrets.M2M_API_KEY = "archive-test-legacy-key"
t.after(() => { secrets.JWT_SECRET = oldSecret; secrets.M2M_API_KEY = oldM2mKey })
const app = Fastify()
app.decorate("db", f.db as any)
await app.register(cookie)
await app.register(authRoutes)
await app.register(async protectedApp => {
await protectedApp.register(authPlugin)
protectedApp.get("/check", async () => ({ success: true }))
protectedApp.get("/mcp", async () => ({ success: true }))
}, { prefix: "/api" })
await app.register(async internal => {
await internal.register(m2mAuthPlugin)
internal.get("/check", async () => ({ success: true }))
}, { prefix: "/internal" })
t.after(() => app.close())
const token = jwt.sign({ user_id: userId, tenant_id: null, is_admin: true }, secrets.JWT_SECRET)
const headers = { authorization: `Bearer ${token}` }
assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 200)
await f.archive()
assert.equal((await app.inject({ url: "/api/check", headers })).statusCode, 401)
assert.equal((await app.inject({ method: "POST", url: "/auth/login", payload: { email: "former@example.test", password: "archive-test-password" } })).statusCode, 401)
assert.equal((await app.inject({ method: "POST", url: "/auth/password/reset", payload: { email: "former@example.test" } })).statusCode, 404)
// Even a session issued concurrently with archiving must fail the current-user check.
f.data.auth_refresh_tokens[0].revoked_at = null
const { hashRefreshToken } = await import("../src/utils/authTokens")
f.data.auth_refresh_tokens[0].token_hash = hashRefreshToken("old-refresh-token")
assert.equal((await app.inject({ method: "POST", url: "/auth/refresh", payload: { refreshToken: "old-refresh-token" } })).statusCode, 401)
f.data.m2m_api_keys[0].active = true
assert.equal((await app.inject({ url: "/api/mcp", headers: { authorization: "Bearer fedeo_mcp_archive_test" } })).statusCode, 401)
assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": "fedeo_mcp_archive_test" } })).statusCode, 401)
assert.equal((await app.inject({ url: "/internal/check", headers: { "x-api-key": secrets.M2M_API_KEY, "x-user-id": userId, "x-tenant-id": "7" } })).statusCode, 401)
})

View File

@@ -328,18 +328,21 @@ Datenbank und `ENCRYPTION_KEY` gemeinsam sichern; der private Instanzschlüssel
### Prüfung und verbleibender Ausbau ### Prüfung und verbleibender Ausbau
Geprüft wurden 21 Tests ohne Überspringen auf einer isolierten PostgreSQL-17-Testdatenbank. Darunter sind die Absicherung bestehender Chatgrenzen, die Trennung von Gast- und regulären Sitzungen, benachbarte Unterräume, der Historienbeginn, abgelaufene und widerrufene Einladungen sowie zwei unabhängige Instanzdatenbanken mit echten Fastify-Routen und Signaturprüfung. Der Transport zwischen diesen Instanzen ist im Test simuliert: Verbindungsfehler, verlorene Bestätigungen, doppelte Zustellung, veraltete Teilnahmeversionen, verspätete Widerrufe und erneute Einladungen werden gezielt erzeugt. HTTPS/DNS-Einschränkungen und Signaturmanipulationen besitzen zusätzliche Tests. Eine Verbindung zwischen zwei produktiven HTTPS-Installationen wurde nicht ausgeführt. Geprüft wurden 50 Tests ohne Überspringen auf einer isolierten PostgreSQL-17-Testdatenbank. Darunter sind die Absicherung bestehender Chatgrenzen, die Trennung von Gast- und regulären Sitzungen, benachbarte Unterräume, der Historienbeginn, abgelaufene und widerrufene Einladungen sowie zwei unabhängige Instanzdatenbanken mit echten Fastify-Routen und Signaturprüfung. Die Benachrichtigungstests prüfen zusätzlich Empfängermandanten, persönliche Kanalwahl, Lesestatus, wiederholte Synchronisation, konkurrierende Worker, Neustarts und gemeinsame Transaktions-Rollbacks. Der Transport zwischen diesen Instanzen und die externe Benachrichtigungszustellung sind im Test simuliert: Verbindungsfehler, verlorene Bestätigungen, doppelte Zustellung, veraltete Teilnahmeversionen, verspätete Widerrufe und erneute Einladungen werden gezielt erzeugt. HTTPS/DNS-Einschränkungen und Signaturmanipulationen besitzen zusätzliche Tests. Eine Verbindung zwischen zwei produktiven HTTPS-Installationen wurde nicht ausgeführt.
Reproduzierbar aus `backend/` mit einer ausdrücklich bereitgestellten, entbehrlichen Datenbank namens `collaboration_test`: Reproduzierbar aus `backend/` mit einer ausdrücklich bereitgestellten, entbehrlichen Datenbank namens `collaboration_test`:
```sh ```sh
COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collaboration_test \ COLLABORATION_TEST_DATABASE_URL=postgresql://USER:PASSWORD@127.0.0.1:PORT/collaboration_test \
node --import tsx --test tests/communicationAccess.test.ts tests/collaboration.test.ts \ node --import tsx --test tests/communicationAccess.test.ts tests/collaboration.test.ts \
tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts tests/chatContent.test.ts tests/collaborationRoutes.test.ts tests/collaborationFederation.test.ts tests/chatContent.test.ts \
tests/collaborationNotifications.test.ts tests/notificationPreferences.test.ts
``` ```
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. Die spätere Erweiterung um Emojis, Bilder und Reaktionen wurde zusätzlich mit einer lokalen Browser-Vorschau der tatsächlichen Vue-Komponenten und simulierten API-Antworten geprüft. Die automatisierten Medientests prüfen unter anderem Bildvalidierung, Bildnachrichten ohne Text, Reaktionszähler, Historiengrenzen, Gastrechte, deaktivierte Projektchats und signierte Zustellung zwischen zwei Instanzen. 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. Die spätere Erweiterung um Emojis, Bilder und Reaktionen wurde zusätzlich mit einer lokalen Browser-Vorschau der tatsächlichen Vue-Komponenten und simulierten API-Antworten geprüft. Die automatisierten Medientests prüfen unter anderem Bildvalidierung, Bildnachrichten ohne Text, Reaktionszähler, Historiengrenzen, Gastrechte, deaktivierte Projektchats und signierte Zustellung zwischen zwei Instanzen.
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. Nachrichten und persönliche Einladungen aus der Zusammenarbeit unterstützen jetzt In-App-, E-Mail- und Push-Benachrichtigungen. Die Einstellungen gelten im Heimatmandanten des Empfängers. Die Migration `0083_collaboration_notifications.sql` muss dafür eingespielt sein. Der Versand erfolgt nach dem Speichern über den Hintergrundworker und prüft die aktuellen Zugriffsrechte; wiederholte Synchronisationen erzeugen keine doppelten Benachrichtigungen. Details und Standardwerte stehen in [Persönliche Benachrichtigungen](notification-preferences.md). Die Zustellung auf realen Endgeräten hängt weiterhin von der jeweiligen Push-Konfiguration ab.
Die nächste Ausbaustufe aus Schritt 3 umfasst insbesondere native Mobile-Parität, 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. 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.

309
docs/fahrtenbuch-konzept.md Normal file
View File

@@ -0,0 +1,309 @@
# Konzept: Fahrtenbuch mit direkter Teltonika-Anbindung
## Zielbild
Die Teltonika-OBD-II-Tracker senden ihre AVL-Daten direkt an FEDEO. Traccar entfällt vollständig aus der produktiven Kette.
```text
Teltonika-Tracker
│ TCP/TLS, Codec 8 Extended
▼
FEDEO Tracking-Ingest
│ atomare Speicherung, danach ACK
▼
PostgreSQL ──► Fahrtbildungs-Worker ──► Fahrtenbuch-API ──► Nuxt-Oberfläche/PDF-Export
```
Die empfohlene Lösung besteht aus einem kleinen, eigenständigen Dienst `tracking-server`, den bestehenden FEDEO-Diensten und PostgreSQL. Der Empfangsdienst darf nicht Teil des HTTP-Prozesses sein: Teltonika verwendet langlebige TCP-Verbindungen und ein Binärprotokoll, während das bestehende Backend Fastify/HTTP bedient. Die getrennten Prozesse können unabhängig neu gestartet und skaliert werden.
Ein zusätzlicher Message-Broker ist für die derzeit erkennbare Größenordnung nicht nötig. Der Tracker puffert Daten bei Verbindungsproblemen; PostgreSQL übernimmt die dauerhafte Übergabe an den Fahrtbildungs-Worker.
## Annahmen und noch zu bestätigende Angaben
- Mit „Teltonika OBD2“ ist voraussichtlich ein Gerät der FMx003-Familie gemeint, zum Beispiel FMB003, FMC003 oder FMM003. Vor der Implementierung müssen exaktes Modell, Firmwarestand und Mobilfunkvariante feststehen.
- Die Geräte werden auf **Codec 8 Extended über TCP/TLS** eingestellt. Teltonika empfiehlt Codec 8 Extended, weil nur damit auch AVL-IDs oberhalb von 255 und variable I/O-Werte vollständig übertragen werden können.
- Das Fahrtenbuch soll nicht nur eine Streckenübersicht, sondern bei Bedarf ein steuerlich nutzbares elektronisches Fahrtenbuch für Deutschland sein.
- Ein OBD-Tracker erkennt das Fahrzeug, aber nicht automatisch die fahrende Person. Ohne zusätzlichen BLE-/iButton-Fahrerschlüssel wird der im Fahrzeug hinterlegte Standardfahrer vorgeschlagen und muss gegebenenfalls geändert werden.
## 1. Tracking-Ingest
### Aufgabe
Ein neues Paket beziehungsweise ein neuer Dienst `tracking-server/` wird in TypeScript implementiert. Er verwendet `node:tls` beziehungsweise während der lokalen Entwicklung `node:net` und übernimmt ausschließlich:
1. TCP-Verbindung und IMEI-Handshake,
2. Prüfung, ob die IMEI einem aktiven FEDEO-Fahrzeug zugeordnet ist,
3. Framing und Parsen von Codec 8 (`0x08`) und Codec 8 Extended (`0x8E`),
4. Prüfung von Paketlänge, doppelter Datensatzanzahl und CRC-16/IBM,
5. normalisierte Speicherung aller AVL-Datensätze in einer Datenbanktransaktion,
6. Teltonika-ACK erst nach erfolgreichem Commit,
7. Aktualisierung von `last_seen_at` und einfachen Betriebsmetriken.
Der produktive Gerätevertrag ist Codec 8 Extended. Codec 8 sollte der Parser trotzdem beherrschen, damit ein falsch oder noch nicht umkonfiguriertes Gerät sichtbar diagnostiziert werden kann.
Bei Datenbankfehlern wird **kein** positiver ACK gesendet. Das Gerät behält die Datensätze und versucht die Übertragung erneut. Doppelte Zustellungen müssen durch einen fachlichen Idempotenzschlüssel wirkungslos bleiben. Unbekannte IMEIs werden abgewiesen und nur als begrenztes Sicherheitsereignis protokolliert.
### Parser-Grenzen
Der Binärparser darf nicht mit Fachlogik vermischt werden. Sein Ergebnis ist ein neutrales Objekt:
```ts
type AvlRecord = {
recordedAt: Date
priority: number
longitude: number
latitude: number
altitude: number
angle: number
satellites: number
speedKmh: number
eventIoId: number
io: Record<number, bigint | Buffer>
}
```
Für die Fachlogik gibt es danach ein modell- und firmwareabhängiges I/O-Mapping. Relevante Standardwerte sind typischerweise:
- AVL 239: Zündung,
- AVL 240: Bewegung,
- AVL 16: Teltonika-Gesamtkilometerzähler,
- AVL 389: OEM-Gesamtkilometerstand,
- AVL 256: VIN,
- AVL 134: Fahrtdistanz.
Einheiten und Verfügbarkeit werden nicht im Parser fest verdrahtet, sondern über getestete Definitionen je Gerätefamilie normalisiert. Insbesondere der OEM-Kilometerstand ist vom unterstützten Fahrzeug und der auf das Gerät geladenen OBD-Konfiguration abhängig.
### Sicherheit und Betrieb
- Eigener TCP-Port, zum Beispiel `5027`, mit TLS 1.2; die konkrete TLS-Unterstützung muss für Modell und Firmware geprüft werden.
- IMEI-Zulassungsliste aus der Datenbank, Rate-Limit pro Quell-IP und harte Grenzen für Paketgröße und Verbindungsdauer.
- Keine Befehlssteuerung in Version 1. Codec 12/14 kann später getrennt ergänzt werden.
- Metriken: aktive Verbindungen, akzeptierte/abgelehnte Pakete, CRC-Fehler, unbekannte IMEIs, Verzögerung `received_at - recorded_at`, letzter Datensatz je Tracker und offene Fahrten.
- Alarm, wenn ein aktiver Tracker länger als eine konfigurierbare Zeit keine Daten liefert.
## 2. Datenmodell
Die vorhandene Tabelle `vehicles` bleibt die fachliche Fahrzeugquelle. Die generische Tabelle `devices` sollte nicht mit GPS-Sonderfeldern überladen werden.
### `vehicle_trackers`
Zuordnung und Gerätekonfiguration:
- `id uuid`, `tenant bigint`, `vehicle bigint`
- `imei text unique`, `model text`, `firmware text`
- `protocol text`, `enabled boolean`
- `installed_at`, `removed_at`, `last_seen_at`
- `io_profile_version text`
- eindeutige aktive Zuordnung eines Trackers zu einem Fahrzeug
Eine zeitliche Zuordnung ist wichtig: Beim Umbau eines Trackers dürfen alte Positionen nicht nachträglich dem neuen Fahrzeug zugerechnet werden.
### `tracking_packets`
Technische Nachvollziehbarkeit und gezielte Fehleranalyse:
- Tracker, Empfangszeit, Codec, CRC, Datensatzanzahl
- SHA-256 des vollständigen Frames als Idempotenzschlüssel
- optionaler Rohframe als `bytea` mit kurzer, konfigurierbarer Aufbewahrung, zum Beispiel 30 oder 90 Tage
- Parse-Status und Fehlercode
### `tracking_positions`
Unveränderliche normalisierte Messpunkte:
- Mandant, Tracker und die zum Messzeitpunkt gültige Fahrzeugzuordnung
- Gerätezeit `recorded_at` und Serverzeit `received_at`
- Koordinaten, Höhe, Richtung, Geschwindigkeit, Satelliten, Priorität
- normalisierte Werte `ignition`, `movement`, `odometer_m`, `oem_odometer_m`
- `event_io_id` und vollständige I/O-Werte als `jsonb`
- Qualitätsmerkmale wie `valid_gnss`, Zeitabweichung und Kilometerstandsquelle
Eindeutigkeit entsteht aus Tracker, Gerätezeit und einem stabilen Datensatz-Fingerabdruck. Für größere Datenmengen kann die Tabelle später monatsweise nach `recorded_at` partitioniert werden. Indizes werden mindestens auf `(vehicle, recorded_at)` und `(tracker, recorded_at)` benötigt.
### `tracking_trips`
Das fachliche Fahrtenbuch:
- Fahrzeug, Fahrer, Start/Ende und Start-/Zielkoordinaten
- Start-/Zieladresse als zum Abschlusszeitpunkt gespeicherter Text
- Kilometerstand Beginn/Ende, berechnete Distanz und jeweilige Quelle
- Klassifizierung `betrieblich`, `privat` oder `wohnung_erste_taetigkeitsstaette`
- Reisezweck, Kunde/Geschäftspartner sowie optionale FEDEO-Verknüpfungen zu Kunde, Projekt und Termin
- Status `offen`, `zu_pruefen`, `bestaetigt`, `gesperrt`
- Qualitätsstatus mit konkreten Hinweisen, zum Beispiel fehlender OEM-Kilometerstand, GPS-Lücke oder unplausibler Sprung
- Referenzen auf ersten und letzten Messpunkt
### `tracking_trip_revisions` und `tracking_logbook_exports`
Fahrten werden nie unbemerkt überschrieben. Jede Änderung erzeugt eine unveränderliche Revision mit altem und neuem Wert, Benutzer, Zeit, Änderungsgrund sowie einer Hash-Verkettung zur vorherigen Revision. Auch Zusammenführen, Teilen und Löschen sind fachliche Revisionen; ein physisches Löschen bestätigter Fahrten ist nicht erlaubt.
Ein abgeschlossener Monats- oder Jahresexport speichert PDF, maschinenlesbare CSV/JSON-Daten und ein Manifest mit SHA-256-Prüfsummen. Der Export enthält die Änderungshistorie in gewöhnlich einsehbarer Form. „Gesperrt“ bedeutet: weitere Korrekturen erzeugen eine sichtbare Folgerevision und gegebenenfalls eine neue Exportversion.
## 3. Fahrtbildung
Die Fahrtbildung läuft als wiederholbarer Worker auf Ereigniszeit, nicht auf Empfangszeit. Das ist wichtig, weil Tracker bei Funklöchern ältere Datensätze nachliefern.
### Zustandsautomat je Tracker
```text
STEHT
└─ Zündung an + Bewegung/Geschwindigkeit ─► FÄHRT
FÄHRT
├─ weitere Punkte ────────────────────────► FÄHRT
└─ Zündung aus + Nachlaufzeit ────────────► STEHT + Fahrt abschließen
```
- Primäres Start-/Endesignal ist AVL 239 (Zündung), plausibilisiert mit Bewegung und Geschwindigkeit.
- Eine konfigurierbare Nachlaufzeit von zunächst 120 Sekunden verhindert Fahrtteilungen an Ampeln oder bei kurzen Signalwechseln.
- Eine Fahrt wird nicht sofort endgültig: Ein Watermark, zum Beispiel zehn Minuten nach dem letzten bekannten Gerätezeitpunkt, erlaubt verspätete Datensätze. Betroffene offene Zeitfenster werden deterministisch neu berechnet.
- Bei fehlendem Zündungssignal greift ein klar markierter Fallback aus Bewegung, Geschwindigkeit und Stillstandszeit.
- Kilometerstandsquelle in absteigender Qualität: OEM-Gesamtkilometerstand (AVL 389), kalibrierter Teltonika-Gesamtkilometerzähler (AVL 16), Fahrtdistanz/Positionssumme. FEDEO speichert Quelle und Abweichung, statt Werte still zu vermischen.
- Unplausible GNSS-Punkte werden nicht für die Distanz verwendet, bleiben aber unverändert gespeichert.
- Zeitrücksprünge, Kilometerzählersprünge, GPS-Lücken und Tracker-Ausbau erzeugen Prüffälle und niemals stillschweigend „saubere“ Fahrten.
Die Zählerwerte müssen im Pilotfahrzeug gegen den Tachostand geprüft werden. Liefert ein Fahrzeug keinen OEM-Gesamtkilometerstand, wird einmalig ein dokumentierter Tachostand mit dem Teltonika-Zähler verknüpft. Der daraus entstehende Offset bleibt versioniert.
## 4. Bedienung in FEDEO
Der vorhandene, bislang nur konfigurierte Ressourcenschlüssel `trackingtrips` kann weiterverwendet werden. Für das Fahrtenbuch ist dennoch eine eigene Seite sinnvoller als die generische Standard-Entity-Maske.
### Ansichten
- **Fahrten:** chronologische Liste mit Fahrzeug, Fahrer, Start/Ziel, Zeit, Kilometerständen, Distanz, Klassifizierung, Zweck und Prüfstatus.
- **Prüfen:** schnelle Stapelbearbeitung aller offenen Fahrten; Vorschläge aus Kalender, Kunde/Projekt und wiederkehrenden Zielen.
- **Karte:** Route einer ausgewählten Fahrt, nicht standardmäßig die vollständige Mitarbeiterhistorie.
- **Fahrzeug:** Trackerstatus, letzter Empfang, Kilometerstandsqualität und offene Warnungen.
- **Export:** Monats-/Jahresabschluss, PDF-Vorschau und Prüfdateien.
Eine betriebliche Fahrt verlangt Ziel, Reisezweck und Geschäftspartner beziehungsweise einen konkreten betrieblichen Vorgang. Privatfahrten speichern für normale Benutzer nur die steuerlich erforderlichen Werte; die Sichtbarkeit genauer Privat-Routen sollte über eine eigene Berechtigung begrenzt werden.
### API-Schnittstellen
Spezialisierte, mandantengeprüfte Routen sind der generischen Ressourcen-API vorzuziehen:
- `GET /api/tracking/trips`
- `GET /api/tracking/trips/:id`
- `PATCH /api/tracking/trips/:id/classification`
- `POST /api/tracking/trips/:id/confirm`
- `POST /api/tracking/trips/:id/split`
- `POST /api/tracking/trips/merge`
- `GET /api/tracking/vehicles/:id/status`
- `POST /api/tracking/exports`
- `GET /api/tracking/exports/:id`
Ändernde Aufrufe verlangen einen Änderungsgrund, sobald eine Fahrt bereits bestätigt war. Berechtigungen sollten mindestens `tracking.view`, `tracking.edit_own`, `tracking.edit_all`, `tracking.view_private_routes`, `tracking.manage_devices`, `tracking.lock` und `tracking.export` unterscheiden.
## 5. Steuerliche Nachvollziehbarkeit und Datenschutz
Für ein deutsches elektronisches Fahrtenbuch reichen GPS-Punkte allein nicht. Nach der Rechtsprechung müssen die Aufzeichnungen zeitnah, fortlaufend und in geschlossener Form vorliegen. Änderungen müssen technisch ausgeschlossen oder in ihrer Reichweite direkt im Fahrtenbuch erkennbar sein. Für betriebliche Fahrten gehören grundsätzlich Datum, Kilometerstand zu Beginn und Ende, Reiseziel, Reisezweck und aufgesuchte Geschäftspartner dazu.
Deshalb sind folgende Punkte keine spätere Zusatzfunktion, sondern Bestandteil der ersten fachlich nutzbaren Version:
- append-only Messdaten und Änderungshistorie,
- sichtbare Revisionsangaben im Fahrtenbuch und im Export,
- Erinnerung und Eskalation für zeitnahe Klassifizierung,
- Vollständigkeits- und Plausibilitätsprüfung pro Fahrzeug,
- reproduzierbare, versionierte Monatsabschlüsse,
- dokumentierte Verfahrensbeschreibung und Wiederherstellungstests.
Das Konzept ist eine technische Auslegung, keine Zusicherung der steuerlichen Anerkennung. Export und Verfahrensdokumentation sollten vor dem produktiven steuerlichen Einsatz mit der Steuerberatung abgestimmt werden.
Standortdaten von Beschäftigten erfordern zudem ein Rollen- und Löschkonzept, Zweckbindung, transparente Information und gegebenenfalls Beteiligung des Betriebsrats. Rohpunkte sollten nur so lange wie fachlich und rechtlich erforderlich gespeichert werden. Reverse-Geocoding erfolgt nur für Start und Ziel, asynchron und mit Cache; ein selbst betriebener Dienst oder ein vertraglich geprüfter Anbieter vermeidet unnötige Weitergabe vollständiger Bewegungsprofile.
## 6. Gerätekonfiguration
Für einen Pilot-Tracker wird ein versioniertes Teltonika-Konfigurationsprofil erstellt:
- Hauptserver: FEDEO-Domain und Tracking-Port
- Transport: TCP mit TLS, sofern Modell/Firmware dies unterstützen
- Datenprotokoll: Codec 8 Extended
- Datenerfassung in Bewegung zunächst alle 30 Sekunden oder 100 Meter sowie bei relevanter Richtungsänderung
- Ereignisdatensatz bei Zündung an/aus
- AVL 239, 240, 16, 389, 256 und 134 aktivieren, soweit vom Gerät unterstützt
- lokale Pufferung und älteste Datensätze zuerst senden
- Serverbestätigung auf AVL-Ebene
Das Standardintervall von mehreren Minuten ist für belastbare Start-/Zielpunkte und Routen zu grob. Die endgültigen Intervalle werden im Pilot gegen Datenvolumen, SIM-Tarif und Fahrzeugbatterie getestet.
## 7. Einführung ohne Datenverlust
### Phase 0 – Geräte-Spike
Ein reales Gerät und Fahrzeug werden festgelegt. Modell, Firmware, IMEI, empfangene AVL-IDs, Zündungslogik und Kilometerstandsquellen werden dokumentiert. Ein aufgezeichneter Binärdatenbestand aus definierten Testfahrten bildet anschließend einen dauerhaft nutzbaren Parser- und Fahrtbildungs-Testvektor.
### Phase 1 – Shadow-Ingest
Teltonika unterstützt einen zweiten Server und einen Duplikatmodus. Während der Pilotphase sendet das Gerät an Traccar **und** FEDEO. FEDEO bestätigt und speichert Daten, beeinflusst aber noch kein produktives Fahrtenbuch. Über mehrere Wochen werden Paketvollständigkeit, Kilometerstände und erkannte Fahrten verglichen.
### Phase 2 – Fahrtenbuch-Pilot
Fahrtbildung, Prüfansicht, Klassifizierung, Revisionen und Export werden für wenige Fahrzeuge aktiviert. Kriterien für die Freigabe:
- kein Paketverlust bei Neustart oder Datenbankunterbrechung,
- idempotente Verarbeitung wiederholter Pakete,
- reproduzierbare Fahrtbildung bei ungeordneter Zustellung,
- Kilometerstandsabweichungen erklärt und sichtbar,
- vollständiger Export inklusive Änderungen,
- Rechte- und Mandantentrennung getestet.
### Phase 3 – Umschaltung
Nach erfolgreichem Parallelbetrieb wird FEDEO Hauptserver. Traccar bleibt zunächst als Teltonika-Backupserver konfiguriert, anschließend wird es entfernt. Der Rückfall besteht in der ersten Zeit lediglich aus dem Zurückschalten des Geräteprofils; ein Datenbank-Rollback ist nicht erforderlich.
## 8. Empfohlener Implementierungsumfang
### Iteration 1: belastbarer Empfang
- Schemata `vehicle_trackers`, `tracking_packets`, `tracking_positions`
- Codec-8/8E-Parser mit CRC- und offiziellen Beispielvektoren
- TCP/TLS-Dienst, IMEI-Zuordnung, ACK nach Commit
- Trackerstatus im Backend und technische Metriken
- Docker-Compose-Erweiterung und Betriebsdokumentation
### Iteration 2: Fahrten
- Schemata `tracking_trips` und Qualitätsmeldungen
- deterministischer Fahrtbildungs-Worker
- Listen-, Detail- und Kartenansicht
- manuelle Klassifizierung und Fahrerzuordnung
### Iteration 3: belastbares Fahrtenbuch
- unveränderliche Revisionen und Hash-Verkettung
- zeitnahe Erinnerungen, Bestätigung und Monatsabschluss
- PDF/CSV/JSON-Export mit Prüfmanifest
- Projekt-/Kunden-/Kalender-Vorschläge
- Rechte-, Datenschutz- und Verfahrensdokumentation
## 9. Tests, die vor Abschaltung von Traccar bestehen müssen
- fragmentierte und zusammengefasste TCP-Frames
- ungültige Länge, CRC, Codec-ID, IMEI und Datensatzanzahl
- Disconnect vor und nach ACK
- dasselbe Paket mehrfach und gleichzeitig zugestellt
- mehrere AVL-Datensätze pro Paket
- negative Koordinaten und variable I/O-Felder aus Codec 8 Extended
- Offline-Nachlieferung über mehrere Stunden in falscher Empfangsreihenfolge
- Zündung an ohne Bewegung, kurze Stopps, Tunnel, GPS-Sprung und Mitternacht
- fehlender oder zurückgesetzter Kilometerzähler
- Trackerwechsel zwischen Fahrzeugen
- Mandantentrennung jeder API-Route
- Änderung einer bestätigten und einer gesperrten Fahrt
- Export-Reproduzierbarkeit und Prüfung der Hash-Kette
## Empfehlung
Zuerst sollte **nicht** die Oberfläche gebaut werden, sondern ein zwei- bis dreitägiger Geräte-Spike mit einem konkreten Tracker. Danach ist Iteration 1 überschaubar und risikoarm. Der direkte Teltonika-Empfang selbst ist technisch klar definiert; die eigentliche Sorgfalt liegt in verspäteten Daten, Kilometerstandsqualität, Fahreridentität und der unveränderlich sichtbaren Bearbeitungshistorie.
## Quellen
- [Teltonika: Codec 8 und Codec 8 Extended](https://wiki.teltonika-gps.com/view/Teltonika_Data_Sending_Protocols)
- [Teltonika: Empfehlung für Codec 8 Extended](https://wiki.teltonika-gps.com/view/Which_data_protocol_to_choose_Codec_8_or_Codec_8_Extended)
- [Teltonika: FMC003 Data Sending Parameters](https://wiki.teltonika-gps.com/view/FMC003_Teltonika_Data_Sending_Parameters_ID)
- [Teltonika: FMB003 Trip-/Odometer-Einstellungen](https://wiki.teltonika-gps.com/view/FMB003_Trip/Odometer_settings)
- [Teltonika: FMB003 GPRS-, Zweitserver- und TLS-Einstellungen](https://wiki.teltonika-gps.com/view/FMB003_GPRS_settings)
- [Teltonika: OEM-Gesamtkilometerstand und Fahrzeugkompatibilität](https://wiki.teltonika-gps.com/view/Pay_as_You_Drive)
- [Bundesfinanzhof: Anforderungen an ein elektronisches Fahrtenbuch, VI B 37/23](https://www.bundesfinanzhof.de/de/entscheidung/entscheidungen-online/detail/STRE202450011/)
- [Bundesfinanzhof: Inhalt und geschlossene Form des Fahrtenbuchs, VI R 33/10](https://www.bundesfinanzhof.de/de/entscheidung/entscheidungen-online/detail/STRE201210116/)
- [BMF: Einkommensteuer-Handbuch 2025, Anhang 16 III](https://lsth.bundesfinanzministerium.de/esth/2025/B-Anhaenge/Anhang-16/III/inhalt.html)

View File

@@ -1,6 +1,6 @@
# Persönliche Benachrichtigungen # Persönliche Benachrichtigungen
Unter Einstellungen → Benachrichtigungen im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil. Unter Einstellungen → Benachrichtigungen (`/settings/notifications`) im Web und Mehr → Einstellungen in der mobilen App kann jeder Benutzer E-Mail und Push für sein Profil im aktiven Tenant einstellen. Der Web-Menüeintrag ist für alle angemeldeten Mitarbeiter unabhängig von Administrationsrechten sichtbar. Für ein anderes Profil wird zuerst der Tenant gewechselt. Die E-Mail-Adresse stammt aktuell vom Benutzerkonto, nicht vom Mitarbeiterprofil.
## Ereignisse ## Ereignisse
@@ -10,16 +10,23 @@ Unter Einstellungen → Benachrichtigungen im Web und Mehr → Einstellungen in
| `communication.message.mentioned` | Persönliche @-Erwähnung im Raum oder Projektchat | aus | an | | `communication.message.mentioned` | Persönliche @-Erwähnung im Raum oder Projektchat | aus | an |
| `communication.message.room` | Sonstige Nachricht in einem Raum oder Projektchat mit Mitgliedschaft | aus | aus | | `communication.message.room` | Sonstige Nachricht in einem Raum oder Projektchat mit Mitgliedschaft | aus | aus |
| `communication.room.invited` | Aufnahme in einen neu angelegten allgemeinen Chatraum | aus | aus | | `communication.room.invited` | Aufnahme in einen neu angelegten allgemeinen Chatraum | aus | aus |
| `collaboration.message.new` | Neue Nachricht in einem freigegebenen Unterraum oder föderierten Chat, ohne Absender | aus | aus |
| `collaboration.room.invited` | Persönliche Einladung in einen Unterraum oder föderierten Chat | aus | an |
Erwähnte Empfänger erhalten nur das Erwähnungsereignis, nicht zusätzlich das normale Raumereignis. Projektchats sind die nativen FEDEO-Chaträume; föderierte Zusammenarbeit wird hier noch nicht erfasst. Erwähnte Empfänger erhalten nur das Erwähnungsereignis, nicht zusätzlich das normale Raumereignis. Projektchats sind die nativen FEDEO-Chaträume. Die beiden Ereignisse unter „Zusammenarbeit“ gelten für lokale Unterräume, mandantenübergreifende Teilnahmen und föderierte Chats. Sie erscheinen automatisch in den Einstellungen im Web und in der App.
Persönliche Einstellungen haben Vorrang vor Tenant-Standardwerten. Ohne beide Einträge gelten die obigen Standardwerte. In-App bleibt für Direktnachrichten und Erwähnungen aktiv; die neuen optionalen Raumereignisse sind auch In-App standardmäßig aus. Technische Testnachrichten sind nicht Teil der Matrix. Persönliche Einstellungen haben Vorrang vor Tenant-Standardwerten. Ohne beide Einträge gelten die obigen Standardwerte. In-App bleibt für Direktnachrichten, Erwähnungen und die Zusammenarbeit aktiv; die optionalen nativen Raumereignisse sind auch In-App standardmäßig aus. Technische Testnachrichten sind nicht Teil der Matrix.
Für die Zusammenarbeit zählt immer der Heimatmandant des empfangenden Benutzers, auch wenn der Raum einem anderen Mandanten oder einer Partnerinstanz gehört. Benachrichtigt werden nur aktive, berechtigte FEDEO-Benutzer. Gäste ohne Benutzerkonto behalten ihren gesonderten Einladungsversand und ihre Anmeldung per E-Mail-Code.
Nachrichten und Einladungen werden mit ihren Benachrichtigungen atomar gespeichert. Ein Worker verarbeitet die vorgemerkten Kanäle alle fünf Sekunden unabhängig von einem geöffneten Browser. Wiederholte Nachrichtenbefehle, Einladungslieferungen und Synchronisationen erzeugen keine zusätzlichen Einträge; eigene Nachrichten und rückwärts nachgeladener Verlauf lösen keine Benachrichtigung aus. Bei Partnerausfällen wartet der Versand auf eine erneute Zugriffsprüfung. Widerrufene oder abgelaufene Teilnahmen und erledigte Einladungen werden vor dem Versand verworfen. Bereits gelesene Nachrichten lösen keine ausstehenden E-Mails oder Pushs mehr aus. Die persönlichen Einstellungen werden vor dem Versand erneut geprüft. Fehler des eigentlichen E-Mail-/Push-Zustellers werden wie bisher als fehlgeschlagen gespeichert; dafür gibt es noch keinen automatischen Wiederholungsversand.
## API und Migration ## API und Migration
- `GET /api/notifications/preferences`: wirksame Einstellungen für den Benutzer und Tenant der authentifizierten Session. - `GET /api/notifications/preferences`: wirksame Einstellungen für den Benutzer und Tenant der authentifizierten Session.
- `PUT /api/notifications/preferences`: Array aus `{eventType, channel, enabled}`; Kanäle `email` und `push`. Unbekannte Ereignisse, nicht erlaubte Kanäle, nicht-boolsche Werte und Duplikate werden mit 400 abgewiesen. Änderungen werden atomar gespeichert. Benutzer- oder Tenant-IDs im Body ändern den Geltungsbereich nicht. - `PUT /api/notifications/preferences`: Array aus `{eventType, channel, enabled}`; Kanäle `email` und `push`. Unbekannte Ereignisse, nicht erlaubte Kanäle, nicht-boolsche Werte und Duplikate werden mit 400 abgewiesen. Änderungen werden atomar gespeichert. Benutzer- oder Tenant-IDs im Body ändern den Geltungsbereich nicht.
- Migration `0079_notification_matrix` registriert die neuen Ereignisse und übernimmt persönliche Einstellungen und Tenant-Standards von `communication.message.new` für Direktnachrichten und Erwähnungen. Sie deaktiviert den alten Sammelauslöser sowie die bisher nicht angebundenen Anrufereignisse. Historische Benachrichtigungen bleiben erhalten. - Migration `0079_notification_matrix` registriert die neuen Ereignisse und übernimmt persönliche Einstellungen und Tenant-Standards von `communication.message.new` für Direktnachrichten und Erwähnungen. Sie deaktiviert den alten Sammelauslöser sowie die bisher nicht angebundenen Anrufereignisse. Historische Benachrichtigungen bleiben erhalten.
- Migration `0083_collaboration_notifications` registriert die beiden Zusammenarbeitsereignisse und indiziert deren wartende Zustellungen. Es werden keine historischen lokalen Nachrichten oder Einladungen nachträglich verschickt. Bei der nächsten Synchronisation können bisher noch nicht abgeholte, ungelesene Partnernachrichten Benachrichtigungen auslösen.
- E-Mail verwendet den SMTP-Versand der Instanz, mobiles Push den zentralen Service, Browser-Push die Web-Push-Konfiguration der Instanz. Ein aktivierter Kanal setzt eine funktionierende Zustellkonfiguration voraus. - E-Mail verwendet den SMTP-Versand der Instanz, mobiles Push den zentralen Service, Browser-Push die Web-Push-Konfiguration der Instanz. Ein aktivierter Kanal setzt eine funktionierende Zustellkonfiguration voraus.
## Sinnvolle nächste Auslöser ## Sinnvolle nächste Auslöser

View File

@@ -41,7 +41,7 @@ Die Pipeline nutzt die fest gepinnten Pakete `@semantic-release/commit-analyzer`
Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt. Es zählt der höchste erforderliche Sprung seit dem letzten stabilen Tag `vMAJOR.MINOR.PATCH`. Eine Änderung und ihr vollständiger Git-Revert innerhalb desselben Release-Bereichs heben sich auf. Selbst verfasste Nachrichten müssen dem Schema entsprechen; echte Merge-Commits und automatisch generierte Git-Reverts werden gesondert behandelt.
Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Beide festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt. Der erste Release heißt `3.0.0`. `bootstrapCommit` in `release.config.json` begrenzt die Prüfung und die erste Änderungsliste auf die Einführung dieser Versionierung auf `dev`. `legacyHistoryHeads` hält zusätzlich den letzten alten `main`-Stand fest (`e7554fa2…` vor der ersten Übernahme von `dev`). Dessen Historie enthält ältere Änderungen an Issue-Vorlagen, die nicht vom `dev`-Ausgangscommit erreichbar sind. Außerdem ist der alte Mobile-Zweig (`89185b4b…`, Stand vor dem Ausgangscommit) eingetragen, der mit `7a1986bb` nach `dev` übernommen wurde. Alle festgelegten Historien werden aus Commit-Prüfung, Versionsberechnung und Release Notes ausgeschlossen. Neue Commits zählen auch dann, wenn ihr Zweig von einem dieser alten Stände abzweigt.
Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch. Ältere Commits werden weder umgeschrieben noch nachträglich klassifiziert. Diese festen Ausgangscommits nach Einführung nicht verschieben und keine späteren Änderungen hinzufügen, um die Commit-Prüfung zu umgehen. Der vollständige Checkout muss auch die alten Branch-Historien enthalten; ein fehlender Ausgangscommit führt zum Abbruch.

View File

@@ -1,5 +1,116 @@
# FEDEO MCP # FEDEO MCP
## Status der Ausgangsbelege
`accounting.outgoing_documents.status.get` lädt den Status eines Belegs anhand seiner numerischen `id`. `accounting.outgoing_documents.status.list` liefert dieselben Statusdaten mit kombinierbaren Filtern und Pagination:
| Filter | Bedeutung |
| --- | --- |
| `state` | Gespeicherter Buchungsstatus, z. B. `Entwurf` oder `Gebucht` |
| `paymentStatus` | `Offen`, `Teilbezahlt`, `Bezahlt` oder `Überbezahlt` |
| `cancelled` | Ob ein aktiver, fertiggestellter Stornobeleg verknüpft ist |
| `type`, `customer`, `project` | Belegart, Kunden-ID und Projekt-ID |
| `limit`, `offset` | Seitengröße und Startposition innerhalb der gefilterten Ergebnisse |
Beispiele für die Parameter von `tools/call`:
```json
{"name":"accounting.outgoing_documents.status.get","arguments":{"id":42}}
```
```json
{"name":"accounting.outgoing_documents.status.list","arguments":{"state":"Gebucht","paymentStatus":"Teilbezahlt","cancelled":false,"limit":25,"offset":0}}
```
Das Ergebnis enthält `state`, `cancelled`, `cancellationDocuments`, `paymentStatus`, `amount`, `paidAmount` und `openAmount` sowie Belegnummer, Belegart, Datum, Kunden- und Projekt-ID. Der Zahlungsstatus gilt für gebuchte, nicht stornierte Rechnungen und Abschlagsrechnungen. Bei Entwürfen, Angeboten, anderen Belegarten und stornierten Rechnungen ist `paymentStatus` gleich `null`.
Die Beträge folgen der Ausgangsbeleganzeige: Positionsnetto auf drei Nachkommastellen, Nettosumme und Steuergruppen (19 % / 7 %) separat auf zwei Nachkommastellen, anschließend Abzug verknüpfter Abschlagsrechnungen. Zahlungen und Stornobelege müssen zum aktiven Mandanten gehören und dürfen nicht archiviert sein; Stornoentwürfe zählen nicht. `openAmount` ist Belegbetrag abzüglich zugeordneter Zahlungen, auch wenn der Beleg storniert wurde. Es ist kein um den Stornobeleg saldierter Kontostand. Bei negativen Rechnungsbeträgen werden Rückzahlungen in der passenden Zahlungsrichtung ausgewertet.
Beide Werkzeuge benötigen `accounting.outgoing_documents.read` und verändern keine Belege oder Buchungen. Die berechneten Statusfilter werden vor `limit`/`offset` angewendet. Dafür liest das Backend bei Bedarf mehrere Stapel von jeweils 100 Belegen; große Filter-Offsets können entsprechend mehr Abfragen benötigen.
## Kalender und Termine
| Werkzeug | Funktion |
| --- | --- |
| `organisation.calendar.options` | Konfigurierte Terminarten, Status und Wiederholungsintervalle laden |
| `organisation.events.list` | Termine und Serien mit Such-, Zeitraum-, Status- und Ressourcenfiltern auflisten |
| `organisation.events.get` | Einen Termin anhand seiner numerischen `id` laden |
| `organisation.events.create` | Termin anlegen; `name` und `startDate` sind Pflichtfelder |
| `organisation.events.update` | Nur angegebene Felder eines Termins ändern |
| `organisation.events.archive` | Termin oder gesamte Wiederholungsserie archivieren |
Lesende Werkzeuge benötigen `organisation.events.read`. Die Schreibwerkzeuge benötigen `organisation.events.write`; dieses Recht ist in den Bootstrap-Administratorrechten enthalten und muss anderen Rollen ausdrücklich zugewiesen werden. Schreibvorgänge protokollieren Benutzer, Zeitpunkt und Änderung in der Historie. Referenzen auf Projekte, Kunden, Lieferanten, Mitarbeiter, Fahrzeuge und Inventar werden auf Verfügbarkeit im aktiven Mandanten geprüft.
```json
{"name":"organisation.events.create","arguments":{"name":"Projektbesprechung","startDate":"2026-10-15T09:00:00+02:00","endDate":"2026-10-15T10:00:00+02:00","state":"Final","repeatInterval":"Wöchentlich","project":42}}
```
Zeitpunkte erfordern ISO-8601 mit `Z` oder explizitem Offset. Das Ende darf nicht vor dem Start liegen; `endDate: null` ist erlaubt. Statuswerte sind `Entwurf` und `Final` (Standard). Wiederholungen sind `Keine Wiederholung` (Standard), `Täglich`, `Wöchentlich`, `2-wöchentlich`, `Monatlich` und `Jährlich`. Weitere bearbeitbare Felder sind `eventtype`, `quick`, `color`, `notes`, `link`, `customer`, `vendor`, `profiles`, `vehicles`, `inventoryitems` und `inventoryitemgroups`.
`profiles` und `inventoryitemgroups` sind Listen von UUIDs; `vehicles` und `inventoryitems` enthalten numerische IDs. Beim Update ersetzen übergebene Listen die bisherigen Zuordnungen; `[]` leert eine Liste, `null` leert nullable Einzelfelder. Wiederholungsänderungen und Archivierung betreffen die gesamte Serie.
```json
{"name":"organisation.events.list","arguments":{"from":"2026-10-12T00:00:00+02:00","to":"2026-10-19T00:00:00+02:00","state":"Final","limit":25}}
```
`from`/`to` filtern überlappende Termine, wobei `to` exklusiv ist. Mit `profile`, `vehicle`, `inventoryitem` oder `inventoryitemgroup` lässt sich nach einzelnen Ressourcenzuordnungen filtern. Weitere Filter sind `query`, `project`, `customer`, `vendor`, `eventtype`, `state`, `quick` und `repeatInterval`.
Die Terminliste liefert gespeicherte Serien-Stammsätze, keine berechneten Einzelvorkommen. Bei gesetztem `from` liefert sie standardmäßig auch früher begonnene Wiederholungsserien als Kandidaten mit. `includeRecurring: false` beschränkt die Zeitraumprüfung auf den gespeicherten Start und das Ende. Auch bei Serien gelten `limit`/`offset` für Stammsätze. Abwesenheiten und Kalender-Abonnementlinks gehören nicht zu diesen Werkzeugen.
## Dokumentenablage und Dateiübertragung
Die Werkzeuge unter `documents.*` stellen die Dokumentenablage und den Zugriff auf gespeicherte Dateien bereit. Alle arbeiten im aktiven Mandanten und schließen archivierte Dateien aus. Portalbenutzer haben keinen Zugriff, auch wenn ihrer Rolle Dokumentenrechte zugewiesen wurden.
| Werkzeug | Funktion |
| --- | --- |
| `documents.inbox.list` / `.count` | Offene Dokumente suchen bzw. zählen |
| `documents.folders.list` / `documents.types.list` | Verfügbare Ordner und Dokumenttypen laden |
| `documents.assignment_kinds.list` | Unterstützte Zuordnungsarten anzeigen |
| `documents.assignments.search` | Zuordnungsziele nach `kind` und optional `search` suchen |
| `documents.inbox.assignments.get` | Bestehende Zuordnungen eines offenen Dokuments laden |
| `documents.inbox.suggestion.get` / `.refresh` | KI-Ablagevorschlag abrufen bzw. erneut anfordern |
| `documents.inbox.file` | Dokument mit Ordner, Typ und optionalen Zuordnungen ablegen |
| `documents.inbox.archive` | Offenes Dokument archivieren |
| `documents.files.list` / `.get` | Gespeicherte Dateien suchen bzw. Details und vorhandenen extrahierten Text laden |
| `documents.files.download` | Originaldatei als Download-URL oder Base64 abrufen |
| `documents.files.upload` | PDF oder Scan in die Dokumentenablage hochladen |
Listen und Suchwerkzeuge verwenden `limit` und `offset` wie unten beschrieben. `search` ist auf 200 Zeichen begrenzt. `documents.files.list` unterstützt zusätzlich `folder`, `type`, `mimeType` sowie Zuordnungsfilter wie `project`, `customer`, `incominginvoice` und `createddocument`. `folder: null` filtert Dateien auf der Hauptebene; ohne `folder` werden alle Ordner durchsucht. Die `id` eines Dokuments ist immer die Datei-UUID, keine Rechnungs- oder Belegnummer.
Lesende Werkzeuge und Downloads benötigen `documents.read`; Upload, Ablegen, Archivieren und erneute KI-Erkennung benötigen `documents.write`. Beide Rechte sind in den Bootstrap-Administratorrechten enthalten. Anderen Rollen müssen sie ausdrücklich zugewiesen werden. Scanner-Zugangsdaten werden für MCP-Uploads nicht benötigt.
### PDF-Download
Zunächst die Datei-UUID beispielsweise über `documents.files.list` mit `createddocument` oder `incominginvoice` ermitteln. Der Aufruf erfolgt über `tools/call` mit diesen Parametern:
```json
{"name":"documents.files.download","arguments":{"id":"00000000-0000-4000-8000-000000000001"}}
```
Standardmäßig enthält das Ergebnis `url`, `method: "GET"`, `authentication: "Bearer"`, Dateiname, MIME-Type und Größe. Die URL verwendet `API_BASE_URL` und den Pfad `/api/mcp/files/<id>/content`; ohne konfigurierte Basis ist sie relativ. Bei `https://app.fedeo.de/backend` lautet der Pfad somit `https://app.fedeo.de/backend/api/mcp/files/<id>/content`.
Die URL ist kein öffentlicher Freigabelink: Der GET-Aufruf benötigt denselben `Authorization: Bearer <MCP-Token>`-Header wie der MCP-Aufruf. Berechtigung, Mandant, Portalzugang und Archivstatus werden beim Download erneut geprüft. Der Inhalt wird als Attachment gestreamt; interne Speicherpfade und Tokens erscheinen nicht im Werkzeugergebnis.
Mit `"format":"base64"` liefert das Werkzeug stattdessen `contentBase64` direkt im Ergebnis, begrenzt auf 20 MiB. Für größere Dateien den URL-Abruf verwenden. Der Download gibt die gespeicherte Originaldatei unverändert zurück; er erzeugt oder konvertiert keine PDFs.
### Upload und Ablage
```json
{"name":"documents.files.upload","arguments":{"filename":"rechnung.pdf","contentBase64":"<Base64-Dateiinhalt>"}}
```
Uploads unterstützen vollständige PDF-, JPEG-, PNG- und TIFF-Dateien bis **20 MiB**. `contentBase64` muss normales, gepaddetes Base64 ohne Data-URL-Präfix enthalten. Dateiname, Dateisignatur und bei PDFs die Abschlussmarkierung werden geprüft. Die Größenbegrenzung des MCP-JSON-Endpunkts berücksichtigt den Base64-Aufschlag; sie gilt für den gesamten Request einschließlich etwaiger Batch-Aufrufe.
Der Upload liefert `id`, `created`, `checksum`, `filename`, `mimeType` und `size`. Er speichert das Original in der Dokumentenablage und stellt die bestehende KI-Vorbereitung in die Warteschlange. Gleicher Dateiname und gleicher Inhalt liefern bei Wiederholung dieselbe ID mit `created: false`, auch nach Ablage oder Archivierung. Ein anderer Inhalt unter demselben Namen wird abgewiesen. Fehlgeschlagene Uploads rollen die Datenbankänderungen zurück und bereinigen das Speicherobjekt.
Anschließend Ordner und Dokumenttyp auswählen und die zurückgegebene UUID ablegen:
```json
{"name":"documents.inbox.file","arguments":{"id":"00000000-0000-4000-8000-000000000001","folder":null,"type":"00000000-0000-4000-8000-000000000002","assignments":{"project":42}}}
```
`folder: null` steht für die Hauptebene. `type` ist erforderlich. Zuordnungen verwenden numerische IDs, außer `check` und `authProfile` (UUIDs). Nur angegebene Zuordnungen werden geändert; `null` entfernt eine Zuordnung. Ordner, Dokumenttyp und Zuordnungsziele müssen im aktiven Mandanten verfügbar sein. Ein fester Dokumenttyp des Ordners wird berücksichtigt. Nach erfolgreicher Ablage verschwindet das Dokument aus `inbox.list`, bleibt aber über `files.list`, `.get` und `.download` erreichbar.
## Listen und Pagination ## Listen und Pagination
Alle datensatzbasierten MCP-Listen und Suchwerkzeuge unterstützen `limit` (ganze Zahl 1–100, Standard 25) und `offset` (ganze Zahl ab 0). Das Wiki behält Standard 50. Die statische Liste der Beleg-Steuertypen benötigt keine Pagination. Alle datensatzbasierten MCP-Listen und Suchwerkzeuge unterstützen `limit` (ganze Zahl 1–100, Standard 25) und `offset` (ganze Zahl ab 0). Das Wiki behält Standard 50. Die statische Liste der Beleg-Steuertypen benötigt keine Pagination.

View File

@@ -1,9 +1,11 @@
<script setup> <script setup>
const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } }) const props = defineProps({ roomId: { type: String, required: true }, api: { type: Function, required: true }, base: { type: String, default: '/api/collaboration' }, embedded: { type: Boolean, default: false } })
const emit = defineEmits(['changed', 'unauthorized']) const emit = defineEmits(['changed', 'unauthorized'])
const room = ref(null), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null), error = ref('') const room = ref(null), messages = ref([]), members = ref([]), error = ref('')
// Guest access has separate credentials and must never reuse an internal user's drafts.
const { draft, draftImage, draftSending, clearDraft, startDraftSend, finishDraftSend } = useChatDraft(() => `collaboration:${props.roomId}`, { persist: () => props.base === '/api/collaboration' })
const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null) const loading = ref(false), sending = ref(false), olderLoading = ref(false), hasMore = ref(false), viewport = ref(null)
let generation = 0, timer, polling = false, pending = null let generation = 0, timer, polling = false
const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}` const endpoint = (id, suffix = '') => `${props.base}/rooms/${encodeURIComponent(id)}${suffix}`
const merge = (incoming) => { const merge = (incoming) => {
const byId = new Map(messages.value.map(m => [m.id, m])) const byId = new Map(messages.value.map(m => [m.id, m]))
@@ -20,7 +22,7 @@ const scroll = async () => { await nextTick(); if (viewport.value) viewport.valu
function failed(e) { function failed(e) {
error.value = messageError(e) error.value = messageError(e)
if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) { if ([401, 403, 404, 410].includes(e?.statusCode || e?.status)) {
room.value = null; messages.value = []; members.value = []; draftImage.value = null room.value = null; messages.value = []; members.value = []
if ((e?.statusCode || e?.status) === 401) emit('unauthorized') if ((e?.statusCode || e?.status) === 401) emit('unauthorized')
} }
} }
@@ -32,7 +34,7 @@ async function read(id, version) {
} }
async function load() { async function load() {
const version = ++generation, id = props.roomId const version = ++generation, id = props.roomId
room.value = null; messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; error.value = ''; pending = null room.value = null; messages.value = []; members.value = []; error.value = ''
loading.value = true; sending.value = false; olderLoading.value = false loading.value = true; sending.value = false; olderLoading.value = false
try { try {
const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))]) const [detail, result, people] = await Promise.all([props.api(endpoint(id)), props.api(endpoint(id, '/messages')), props.api(endpoint(id, '/members'))])
@@ -78,15 +80,18 @@ async function send() {
const text = draft.value.trim(), image = draftImage.value const text = draft.value.trim(), image = draftImage.value
if ((!text && !image) || sending.value || !room.value?.canSend) return if ((!text && !image) || sending.value || !room.value?.canSend) return
const version = generation, id = props.roomId const version = generation, id = props.roomId
if (!pending || pending.text !== text || pending.image !== image) pending = { text, image, clientId: crypto.randomUUID() } const savedDraft = startDraftSend()
if (!savedDraft) return
const pending = { text, image, clientId: savedDraft.value.clientId }
sending.value = true; error.value = '' sending.value = true; error.value = ''
try { try {
const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending }) const message = await props.api(endpoint(id, '/messages'), { method: 'POST', body: pending })
clearDraft(savedDraft)
if (version !== generation) return if (version !== generation) return
merge([message]); draft.value = ''; draftImage.value = null; pending = null merge([message])
await scroll(); await read(id, version) await scroll(); await read(id, version)
} catch (e) { if (version === generation) failed(e) } } catch (e) { if (version === generation) failed(e) }
finally { if (version === generation) sending.value = false } finally { finishDraftSend(savedDraft); if (version === generation) sending.value = false }
} }
const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) const time = value => new Date(value).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' })
watch(() => props.roomId, load, { immediate: true }) watch(() => props.roomId, load, { immediate: true })
@@ -126,6 +131,6 @@ onBeforeUnmount(() => { ++generation; clearInterval(timer) })
</div> </div>
</ChatReactions> </ChatReactions>
</div> </div>
<ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" /> <ChatComposer v-if="room" :key="roomId" v-model="draft" v-model:image="draftImage" :busy="sending || draftSending" :disabled="!room.canSend" :placeholder="room.canSend ? 'Nachricht schreiben …' : 'Dieser Unterraum ist nur lesbar'" @send="send" />
</section> </section>
</template> </template>

View File

@@ -0,0 +1,57 @@
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: null
},
disabled: {
type: Boolean,
default: false
},
label: {
type: String,
default: 'Farbe'
}
})
const emit = defineEmits(['update:modelValue'])
const pickerValue = computed(() => {
const value = props.modelValue?.trim() || ''
if (/^#[\da-f]{6}$/i.test(value)) return value
if (/^#[\da-f]{3}$/i.test(value)) {
return `#${value.slice(1).split('').map(character => character.repeat(2)).join('')}`
}
return '#2563eb'
})
</script>
<template>
<InputGroup class="w-full">
<input
type="color"
:value="pickerValue"
:disabled="disabled"
:aria-label="`${label} auswählen`"
class="h-9 w-14 shrink-0 cursor-pointer rounded border border-default bg-transparent p-1 disabled:cursor-not-allowed disabled:opacity-50"
@input="emit('update:modelValue', $event.target.value)"
>
<UInput
:model-value="modelValue"
:disabled="disabled"
:aria-label="`${label} als Farbwert`"
class="min-w-0 flex-1"
placeholder="Standardfarbe"
@update:model-value="emit('update:modelValue', $event || null)"
/>
<UButton
type="button"
:disabled="disabled || !modelValue"
:aria-label="`${label} zurücksetzen`"
variant="outline"
color="neutral"
icon="i-heroicons-x-mark"
@click="emit('update:modelValue', null)"
/>
</InputGroup>
</template>

View File

@@ -385,6 +385,8 @@ const getEntityModalCreateQuery = (datapoint) => {
const getPostSaveRoute = () => { const getPostSaveRoute = () => {
if (type !== "events") return null if (type !== "events") return null
if (route.query.returnTo === "events") return "/events"
if (route.query.returnTo === "plantafel") { if (route.query.returnTo === "plantafel") {
return { return {
path: "/organisation/plantafel", path: "/organisation/plantafel",
@@ -475,25 +477,21 @@ const updateItem = async () => {
<template> <template>
<UDashboardNavbar <UDashboardNavbar
v-if="!props.inModal" v-if="!props.inModal"
:title="item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen`"
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}" :ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
> >
<template #toggle> <template #toggle>
<div v-if="platform === 'mobile'"></div> <div v-if="platform === 'mobile'"></div>
</template> </template>
<template #left> <template #leading>
<UButton <UButton
icon="i-heroicons-chevron-left" icon="i-heroicons-chevron-left"
variant="outline" variant="outline"
aria-label="Zurück"
@click="router.back()" @click="router.back()"
> >
</UButton> </UButton>
</template> </template>
<template #center>
<h1
v-if="item"
:class="['text-xl','font-medium', 'text-center']"
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
</template>
<template #right> <template #right>
<ArchiveButton <ArchiveButton
color="error" color="error"
@@ -517,7 +515,7 @@ const updateItem = async () => {
Erstellen Erstellen
</UButton> </UButton>
<UButton <UButton
@click="router.push(item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`)" @click="router.push(getPostSaveRoute() || (item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`))"
color="red" color="red"
class="ml-1" class="ml-1"
> >
@@ -527,14 +525,9 @@ const updateItem = async () => {
</UDashboardNavbar> </UDashboardNavbar>
<UDashboardNavbar <UDashboardNavbar
v-else v-else
:title="props.floatingWindow ? '' : (item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen`)"
:ui="{center: 'flex items-stretch gap-1.5 min-w-0'}" :ui="{center: 'flex items-stretch gap-1.5 min-w-0'}"
> >
<template #center>
<h1
v-if="item && !props.floatingWindow"
:class="['text-xl', 'font-medium']"
>{{ item.id ? `${dataType.labelSingle} bearbeiten` : `${dataType.labelSingle} erstellen` }}</h1>
</template>
<template #right> <template #right>
<UButton <UButton
v-if="item.id" v-if="item.id"
@@ -586,7 +579,14 @@ const updateItem = async () => {
:item="item" :item="item"
/> />
</template> </template>
<InputGroup class="w-full" v-if="datapoint.key.includes('.')"> <ColorInput
v-if="datapoint.inputType === 'color'"
:model-value="getDatapointValue(datapoint)"
:label="datapoint.label"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
@update:model-value="(value) => setDatapointValue(datapoint, value)"
/>
<InputGroup class="w-full" v-else-if="datapoint.key.includes('.')">
<UInput <UInput
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"
@@ -833,7 +833,14 @@ const updateItem = async () => {
:item="item" :item="item"
/> />
</template> </template>
<InputGroup class="w-full" v-if="datapoint.key.includes('.')"> <ColorInput
v-if="datapoint.inputType === 'color'"
:model-value="getDatapointValue(datapoint)"
:label="datapoint.label"
:disabled="datapoint.disabledFunction ? datapoint.disabledFunction(item) : false"
@update:model-value="(value) => setDatapointValue(datapoint, value)"
/>
<InputGroup class="w-full" v-else-if="datapoint.key.includes('.')">
<UInput <UInput
:color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'" :color="datapoint.required ? (item[datapoint.key.split('.')[0]][datapoint.key.split('.')[1]] ? 'primary' : 'error') : 'white'"
class="flex-auto" class="flex-auto"

View File

@@ -124,6 +124,16 @@ const invoiceDeliveryNotes = () => {
router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`) router.push(`/createDocument/edit?type=invoices&loadMode=deliveryNotes&linkedDocuments=[${props.item.createddocuments.filter(i => deliveryNoteLikeDocumentTypes.includes(i.type)).map(i => i.id)}]`)
} }
const createAdvanceInvoice = () => {
if (props.topLevelType !== 'projects' || !props.item.id) return
const query = new URLSearchParams(props.queryStringData)
query.set('type', 'advanceInvoices')
query.set('loadMode', 'advanceInvoice')
query.set('project', props.item.id)
router.push(`/createDocument/edit?${query.toString()}`)
}
const showFinalInvoiceConfig = ref(false) const showFinalInvoiceConfig = ref(false)
const referenceDocument = ref(null) const referenceDocument = ref(null)
const advanceInvoicesToAdd = ref([]) const advanceInvoicesToAdd = ref([])
@@ -169,6 +179,13 @@ const selectItem = (item) => {
> >
Dokument aus Vorlage Dokument aus Vorlage
</UButton> </UButton>
<UButton
v-if="props.topLevelType === 'projects'"
icon="i-heroicons-plus"
@click="createAdvanceInvoice"
>
Abschlagsrechnung
</UButton>
<UButton <UButton
@click="showFinalInvoiceConfig = true" @click="showFinalInvoiceConfig = true"
v-if="props.topLevelType === 'projects'" v-if="props.topLevelType === 'projects'"

View File

@@ -38,6 +38,12 @@ const isRouteActive = (to) => {
const links = computed(() => { const links = computed(() => {
const organisationChildren = [ const organisationChildren = [
has("events") && featureEnabled("calendar") ? {
label: "Termine",
to: "/events",
icon: "i-heroicons-calendar-days",
active: route.path.startsWith("/standardEntity/events")
} : null,
has("tasks") && featureEnabled("tasks") ? { has("tasks") && featureEnabled("tasks") ? {
label: "Aufgaben", label: "Aufgaben",
to: "/tasks", to: "/tasks",
@@ -318,6 +324,11 @@ const links = computed(() => {
] ]
const settingsChildren = [ const settingsChildren = [
{
label: "Benachrichtigungen",
to: "/settings/notifications",
icon: "i-heroicons-bell",
},
featureEnabled("settingsNumberRanges") ? { featureEnabled("settingsNumberRanges") ? {
label: "Nummernkreise", label: "Nummernkreise",
to: "/settings/numberRanges", to: "/settings/numberRanges",

View File

@@ -18,9 +18,9 @@ const props = defineProps({
const emit = defineEmits(["updateNeeded","returnData"]) const emit = defineEmits(["updateNeeded","returnData"])
const documentTypeToUse = ref(props.type) const documentTypeToUse = ref(props.type === 'advanceInvoices' ? null : props.type)
const documentTypeItems = computed(() => { const documentTypeItems = computed(() => {
return Object.keys(dataStore.documentTypesForCreation).map((key) => ({ return Object.keys(dataStore.documentTypesForCreation).filter(key => key !== 'advanceInvoices').map((key) => ({
...dataStore.documentTypesForCreation[key], ...dataStore.documentTypesForCreation[key],
key key
})) }))
@@ -83,6 +83,8 @@ const mappings = ref({
}) })
const startImport = () => { const startImport = () => {
if (!selectedDocumentType.value) return
router.push(`/createDocument/edit/?createddocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`) router.push(`/createDocument/edit/?createddocument=${props.id}&type=${documentTypeToUse.value}&optionsToImport=${encodeURIComponent(JSON.stringify(optionsToImport.value))}`)
modal.close() modal.close()
} }
@@ -147,7 +149,7 @@ const startImport = () => {
<UButton color="neutral" variant="ghost" @click="modal.close()"> <UButton color="neutral" variant="ghost" @click="modal.close()">
Abbrechen Abbrechen
</UButton> </UButton>
<UButton @click="startImport"> <UButton :disabled="!selectedDocumentType" @click="startImport">
Kopieren Kopieren
</UButton> </UButton>
</div> </div>

View File

@@ -119,10 +119,13 @@ const reportRows = computed(() => {
relevantCostCentreIds.value.has(account.costCentre) relevantCostCentreIds.value.has(account.costCentre)
) )
// Expenses reduce the cost centre result; incoming documents marked as income increase it.
const sign = invoice.expense === false ? 1 : -1
return matchingAccounts.map((account, index) => { return matchingAccounts.map((account, index) => {
const amountNet = Number(account.amountNet || 0) const amountNet = Number(account.amountNet || 0) * sign
const amountTax = Number(account.amountTax || 0) const amountTax = Number(account.amountTax || 0) * sign
const amountGross = Number(account.amountGross || amountNet + amountTax || 0) const amountGross = Number(account.amountGross || Number(account.amountNet || 0) + Number(account.amountTax || 0) || 0) * sign
const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre)) const accountCostCentre = costCentreMap.value.get(getCostCentreId(account.costCentre))
return { return {
@@ -196,8 +199,7 @@ const reportRows = computed(() => {
const centre = costCentreMap.value.get(getCostCentreId(allocation.costcentre)) const centre = costCentreMap.value.get(getCostCentreId(allocation.costcentre))
const account = accounts.value.find(account => account.id === (allocation.account?.id || allocation.account)) || allocation.ownaccount const account = accounts.value.find(account => account.id === (allocation.account?.id || allocation.account)) || allocation.ownaccount
const amounts = getBankAllocationAmounts(allocation) const amounts = getBankAllocationAmounts(allocation)
// As for incoming documents, expenses are positive and refunds negative. // The bank amount is already signed: outflows (expenses) negative, inflows positive.
const sign = amounts.kind === "input" || (amounts.kind === "none" && amounts.amountGross < 0) ? -1 : 1
return [{ return [{
id: `bank-${allocation.id}`, id: `bank-${allocation.id}`,
sourceLabel: "Bankbuchung", sourceLabel: "Bankbuchung",
@@ -209,9 +211,9 @@ const reportRows = computed(() => {
accountLabel: `${account?.number || ""} ${account?.label || account?.name || ""}`.trim() || "-", accountLabel: `${account?.number || ""} ${account?.label || account?.name || ""}`.trim() || "-",
costCentreName: centre ? `${centre.number} - ${centre.name}` : "-", costCentreName: centre ? `${centre.number} - ${centre.name}` : "-",
description: allocation.description || statement.text || "-", description: allocation.description || statement.text || "-",
amountNet: amounts.amountNet * sign, amountNet: amounts.amountNet,
amountTax: amounts.amountTax * sign, amountTax: amounts.amountTax,
amountGross: amounts.amountGross * sign, amountGross: amounts.amountGross,
}] }]
}) })
return [...incomingRows, ...outgoingRows, ...bankRows] return [...incomingRows, ...outgoingRows, ...bankRows]

View File

@@ -10,10 +10,11 @@ const props = defineProps({
}) })
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {}) const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
.filter(([key]) => key !== 'serialInvoices') .filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key))
.map(([key, value]) => ({ key, ...value }))) .map(([key, value]) => ({ key, ...value })))
const visibleTemplates = computed(() => templates.value.filter(template => { const visibleTemplates = computed(() => templates.value.filter(template => {
return !selectedType.value || template.documentType === selectedType.value return template.documentType !== 'advanceInvoices'
&& (!selectedType.value || template.documentType === selectedType.value)
})) }))
const loadTemplates = async () => { const loadTemplates = async () => {
@@ -25,6 +26,8 @@ const loadTemplates = async () => {
} }
const selectTemplate = (template) => { const selectTemplate = (template) => {
if (template.documentType === 'advanceInvoices') return
const query = new URLSearchParams(props.queryStringData) const query = new URLSearchParams(props.queryStringData)
query.set('templateId', template.id) query.set('templateId', template.id)
query.set('type', template.documentType) query.set('type', template.documentType)

View File

@@ -9,10 +9,12 @@ const props = defineProps({
}) })
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {}) const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
.filter(([key]) => key !== 'serialInvoices') .filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key))
.map(([key, value]) => ({ key, ...value }))) .map(([key, value]) => ({ key, ...value })))
const createDocument = (type) => { const createDocument = (type) => {
if (!documentTypes.value.some(documentType => documentType.key === type)) return
const query = new URLSearchParams(props.queryStringData) const query = new URLSearchParams(props.queryStringData)
query.set('type', type) query.set('type', type)
if (props.projectId) query.set('project', props.projectId) if (props.projectId) query.set('project', props.projectId)

View File

@@ -23,11 +23,13 @@ export type AdminUserProfile = {
last_name: string last_name: string
email?: string | null email?: string | null
active: boolean active: boolean
archived_at: string | null
} }
export type AdminUser = { export type AdminUser = {
id: string id: string
email: string email: string
archived_at: string | null
display_name: string display_name: string
multiTenant: boolean multiTenant: boolean
must_change_password: boolean must_change_password: boolean
@@ -161,8 +163,8 @@ export type CreateInstanceAgentResult = {
export const useAdmin = () => { export const useAdmin = () => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const getOverview = async (): Promise<AdminOverview> => { const getOverview = async (includeArchived = false): Promise<AdminOverview> => {
const response = await $api("/api/admin/overview") as any const response = await $api("/api/admin/overview", { query: { includeArchived } }) as any
return { return {
users: response?.users || [], users: response?.users || [],
@@ -193,6 +195,10 @@ export const useAdmin = () => {
}) })
} }
const archiveUser = async (id: string) => {
return await $api(`/api/admin/users/${id}/archive`, { method: "POST" })
}
const resetUserPassword = async (id: string, password?: string) => { const resetUserPassword = async (id: string, password?: string) => {
return await $api(`/api/admin/users/${id}/reset-password`, { return await $api(`/api/admin/users/${id}/reset-password`, {
method: "POST", method: "POST",
@@ -294,6 +300,7 @@ export const useAdmin = () => {
createUserForProfile, createUserForProfile,
updateUser, updateUser,
resetUserPassword, resetUserPassword,
archiveUser,
updateUserAccess, updateUserAccess,
createTenant, createTenant,
invitePortalUser, invitePortalUser,

View File

@@ -0,0 +1,43 @@
export function useChatDraft(roomKey, { persist = true } = {}) {
const auth = useAuthStore()
const temp = useTempStore()
const apiBase = useRuntimeConfig().public.apiBase
const localDraft = ref(null)
const key = computed(() => {
const room = toValue(roomKey), user = auth.user?.id || auth.user?.user_id
return toValue(persist) && room && user && auth.activeTenant
? JSON.stringify([apiBase, user, String(auth.activeTenant), auth.profile?.id || '', room])
: null
})
watch([key, () => toValue(roomKey), () => auth.user?.id || auth.user?.user_id,
() => auth.activeTenant, () => auth.profile?.id], () => { localDraft.value = null }, { flush: 'sync' })
const current = () => key.value ? temp.getChatDraft(key.value) : localDraft.value
function update(patch) {
const value = { text: '', image: null, ...current(), ...patch, clientId: crypto.randomUUID() }
const next = value.text || value.image ? value : null
if (key.value) temp.setChatDraft(key.value, next)
else localDraft.value = next
}
const draft = computed({ get: () => current()?.text || '', set: text => update({ text }) })
const draftImage = computed({ get: () => current()?.image || null, set: image => update({ image }) })
const draftSending = computed(() => !!key.value && temp.isChatDraftSending(key.value))
const captureDraft = () => ({ key: key.value, value: current() })
function startDraftSend() {
if (draftSending.value) return null
const snapshot = captureDraft()
if (!snapshot.value) return null
if (snapshot.key) temp.startChatDraftSend(snapshot.key, snapshot.value.clientId)
return snapshot
}
function finishDraftSend(snapshot) {
if (snapshot.key) temp.finishChatDraftSend(snapshot.key, snapshot.value.clientId)
}
function clearDraft(snapshot) {
const saved = snapshot.key ? temp.getChatDraft(snapshot.key) : localDraft.value
// A late send response must only remove the draft that was actually sent.
if (!snapshot.value || saved?.clientId !== snapshot.value.clientId) return
if (snapshot.key) temp.setChatDraft(snapshot.key, null)
else localDraft.value = null
}
return { draft, draftImage, draftSending, captureDraft, clearDraft, startDraftSend, finishDraftSend }
}

View File

@@ -45,10 +45,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
// 3. Wenn eingeloggt // 3. Wenn eingeloggt
if (to.path === "/login") { if (to.path === "/login") {
if (auth.user.must_change_password) { return navigateTo(auth.getLandingPath())
return navigateTo("/password-change")
}
return navigateTo(isPortalUser ? "/customer-portal" : "/")
} }
if (auth.user.must_change_password && to.path !== "/password-change") { if (auth.user.must_change_password && to.path !== "/password-change") {

View File

@@ -11,6 +11,10 @@ const userId = route.params.id as string
const loading = ref(true) const loading = ref(true)
const saving = ref(false) const saving = ref(false)
const resettingPassword = ref(false) const resettingPassword = ref(false)
const archiving = ref(false)
const archiveModalOpen = ref(false)
const profileStore = useProfileStore()
const isOwnUser = computed(() => userId === (auth.user?.id || auth.user?.user_id))
const resetPasswordModalOpen = ref(false) const resetPasswordModalOpen = ref(false)
const resetPasswordInput = ref("") const resetPasswordInput = ref("")
const resetPasswordResult = ref("") const resetPasswordResult = ref("")
@@ -102,7 +106,7 @@ const fetchUser = async () => {
loading.value = true loading.value = true
try { try {
const overview = await admin.getOverview() const overview = await admin.getOverview(true)
roles.value = overview.roles roles.value = overview.roles
tenants.value = overview.tenants tenants.value = overview.tenants
unassignedProfiles.value = overview.unassignedProfiles unassignedProfiles.value = overview.unassignedProfiles
@@ -136,7 +140,7 @@ const fetchUser = async () => {
} }
const saveUser = async () => { const saveUser = async () => {
if (!userForm.value || saving.value) return if (!userForm.value || userForm.value.archived_at || saving.value || archiving.value) return
saving.value = true saving.value = true
normalizeUserAssignments() normalizeUserAssignments()
@@ -172,8 +176,24 @@ const saveUser = async () => {
} }
} }
const archiveUser = async () => {
if (!userForm.value || userForm.value.archived_at || archiving.value || isOwnUser.value) return
archiving.value = true
try {
await admin.archiveUser(userForm.value.id)
archiveModalOpen.value = false
await fetchUser()
if (auth.activeTenant) await profileStore.fetchData()
toast.add({ title: "Benutzer dauerhaft archiviert", description: "Alle zugehörigen Profile wurden archiviert und ausgeblendet.", color: "success" })
} catch (err: any) {
toast.add({ title: "Benutzer konnte nicht archiviert werden", description: err?.data?.error || err?.message || "Unbekannter Fehler", color: "error" })
} finally {
archiving.value = false
}
}
const resetPassword = async () => { const resetPassword = async () => {
if (!userForm.value || resettingPassword.value) return if (!userForm.value || userForm.value.archived_at || resettingPassword.value || archiving.value) return
const password = resetPasswordInput.value.trim() const password = resetPasswordInput.value.trim()
if (password && password.length < 8) { if (password && password.length < 8) {
@@ -248,21 +268,40 @@ onMounted(async () => {
</UButton> </UButton>
</template> </template>
<template #right> <template #right>
<UButton
v-if="userForm && !userForm.archived_at && !isOwnUser"
color="error"
variant="soft"
icon="i-heroicons-archive-box"
:disabled="saving || resettingPassword || archiving"
@click="archiveModalOpen = true"
>
Dauerhaft archivieren
</UButton>
<UButton <UButton
color="warning" color="warning"
variant="soft" variant="soft"
icon="i-heroicons-key" icon="i-heroicons-key"
:disabled="loading || !userForm || !!userForm.archived_at || archiving || saving"
@click="openResetPasswordModal" @click="openResetPasswordModal"
> >
Passwort zurücksetzen Passwort zurücksetzen
</UButton> </UButton>
<UButton color="primary" :loading="saving" @click="saveUser"> <UButton color="primary" :loading="saving" :disabled="loading || !userForm || !!userForm.archived_at || archiving || resettingPassword" @click="saveUser">
Speichern Speichern
</UButton> </UButton>
</template> </template>
</UDashboardNavbar> </UDashboardNavbar>
<UDashboardPanelContent> <UDashboardPanelContent>
<UAlert
v-if="userForm?.archived_at"
title="Benutzer dauerhaft archiviert"
:description="`Archiviert am ${new Date(userForm.archived_at).toLocaleString('de-DE')}. Die Anmeldung ist gesperrt. Alle zugehörigen Profile sind archiviert und ausgeblendet.`"
color="warning"
variant="soft"
class="mb-4"
/>
<UCard v-if="!loading && userForm"> <UCard v-if="!loading && userForm">
<div class="flex items-start justify-between gap-4 mb-6"> <div class="flex items-start justify-between gap-4 mb-6">
<div> <div>
@@ -273,7 +312,7 @@ onMounted(async () => {
<USeparator label="Benutzer" /> <USeparator label="Benutzer" />
<UForm :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4"> <UForm :disabled="!!userForm.archived_at || archiving" :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4">
<UFormField label="E-Mail"> <UFormField label="E-Mail">
<UInput v-model="userForm.email" /> <UInput v-model="userForm.email" />
</UFormField> </UFormField>
@@ -341,6 +380,7 @@ onMounted(async () => {
<UFormField label="Rolle"> <UFormField label="Rolle">
<USelectMenu <USelectMenu
:disabled="!!userForm.archived_at || archiving"
:model-value="getRoleForTenant(tenantId)" :model-value="getRoleForTenant(tenantId)"
:items="getRoleOptionsForTenant(tenantId)" :items="getRoleOptionsForTenant(tenantId)"
value-key="value" value-key="value"
@@ -352,6 +392,7 @@ onMounted(async () => {
<UFormField label="Freies Profil"> <UFormField label="Freies Profil">
<USelectMenu <USelectMenu
:disabled="!!userForm.archived_at || archiving"
:model-value="getProfileAssignmentForTenant(tenantId)" :model-value="getProfileAssignmentForTenant(tenantId)"
:items="[ :items="[
{ label: 'Neues Profil erzeugen', value: null }, { label: 'Neues Profil erzeugen', value: null },
@@ -390,7 +431,7 @@ onMounted(async () => {
variant="subtle" variant="subtle"
color="gray" color="gray"
> >
{{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }} {{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }}{{ profile.archived_at ? " · Archiviert" : "" }}
</UBadge> </UBadge>
</div> </div>
</UCard> </UCard>
@@ -398,6 +439,27 @@ onMounted(async () => {
<USkeleton v-if="loading" class="h-80" /> <USkeleton v-if="loading" class="h-80" />
</UDashboardPanelContent> </UDashboardPanelContent>
<UModal v-model:open="archiveModalOpen" :dismissible="!archiving">
<template #content>
<UCard>
<template #header>
<div class="text-lg font-semibold">Benutzer dauerhaft archivieren</div>
</template>
<div class="space-y-3 text-sm">
<p><strong>{{ userForm?.display_name }}</strong> ({{ userForm?.email }}) wird dauerhaft archiviert.</p>
<p>Die Anmeldung wird gesperrt, bestehende Sitzungen und API-Schlüssel werden ungültig. Alle {{ userForm?.profiles.length || 0 }} zugehörigen Profile werden in sämtlichen Tenants archiviert und aus Listen und Auswahllisten ausgeblendet.</p>
<p>Bestehende Dokumente, Zeiterfassungen und Zuordnungen bleiben erhalten. Die Archivierung kann nicht rückgängig gemacht werden.</p>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<UButton variant="soft" color="neutral" :disabled="archiving" @click="archiveModalOpen = false">Abbrechen</UButton>
<UButton color="error" icon="i-heroicons-archive-box" :loading="archiving" @click="archiveUser">Dauerhaft archivieren</UButton>
</div>
</template>
</UCard>
</template>
</UModal>
<UModal v-model:open="resetPasswordModalOpen"> <UModal v-model:open="resetPasswordModalOpen">
<template #content> <template #content>
<UCard> <UCard>

View File

@@ -12,6 +12,7 @@ const createUserModalOpen = ref(false)
const createdUserPassword = ref("") const createdUserPassword = ref("")
const users = ref<AdminUser[]>([]) const users = ref<AdminUser[]>([])
const searchString = ref("") const searchString = ref("")
const showArchived = ref(false)
const createUserForm = ref({ const createUserForm = ref({
email: "", email: "",
@@ -27,13 +28,15 @@ const templateColumns = [
{ key: "email", label: "E-Mail" }, { key: "email", label: "E-Mail" },
{ key: "tenant_count", label: "Tenants" }, { key: "tenant_count", label: "Tenants" },
{ key: "is_admin", label: "Admin" }, { key: "is_admin", label: "Admin" },
{ key: "status", label: "Status" },
] ]
const filteredRows = computed(() => { const filteredRows = computed(() => {
const search = searchString.value.trim().toLowerCase() const search = searchString.value.trim().toLowerCase()
const rows = users.value.map((user) => ({ const rows = users.value.filter((user) => showArchived.value || !user.archived_at).map((user) => ({
...user, ...user,
tenant_count: user.tenant_ids.length, tenant_count: user.tenant_ids.length,
status: user.archived_at ? "Archiviert" : "Aktiv",
is_admin: user.is_admin ? "Ja" : "Nein", is_admin: user.is_admin ? "Ja" : "Nein",
})) }))
@@ -50,7 +53,7 @@ const fetchUsers = async () => {
loading.value = true loading.value = true
try { try {
const overview = await admin.getOverview() const overview = await admin.getOverview(true)
users.value = overview.users users.value = overview.users
} catch (err: any) { } catch (err: any) {
console.error("[administration/users/index]", err) console.error("[administration/users/index]", err)
@@ -119,6 +122,7 @@ onMounted(async () => {
<template> <template>
<UDashboardNavbar title="Administration: Benutzer" :badge="filteredRows.length"> <UDashboardNavbar title="Administration: Benutzer" :badge="filteredRows.length">
<template #right> <template #right>
<UCheckbox v-model="showArchived" label="Archivierte anzeigen" />
<UInput <UInput
v-model="searchString" v-model="searchString"
icon="i-heroicons-magnifying-glass" icon="i-heroicons-magnifying-glass"

View File

@@ -7,12 +7,13 @@ const router = useRouter()
const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([]) const baseRooms = ref([]), projectRooms = ref([]), directRooms = ref([]), collaborationRooms = ref([])
const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('') const invitations = ref([]), invitationBusy = ref(''), collaborationError = ref(''), listError = ref('')
const unreadRooms = ref({}), messages = ref([]), members = ref([]), draft = ref(''), draftImage = ref(null) const unreadRooms = ref({}), messages = ref([]), members = ref([])
const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein' const requestedRoom = () => typeof route.query.room === 'string' ? route.query.room : 'allgemein'
const activeRoomKey = ref(requestedRoom()) const activeRoomKey = ref(requestedRoom())
const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false) const loading = ref(true), roomLoading = ref(false), sending = ref(false), creating = ref(false)
const showRoomForm = ref(false), messagesViewport = ref(null) const showRoomForm = ref(false), messagesViewport = ref(null)
const hiddenRoomKeys = ref([]), showHiddenRooms = ref(false), preferenceBusy = ref(''), preferenceError = ref('') const hiddenRoomKeys = ref([]), showHiddenRooms = ref(false), preferenceBusy = ref(''), preferenceError = ref('')
const roomSearch = ref('')
const preferencesReady = ref(false) const preferencesReady = ref(false)
const roomForm = reactive({ name: '', key: '', topic: '' }) const roomForm = reactive({ name: '', key: '', topic: '' })
const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`) const session = computed(() => `${auth.user?.id || auth.user?.user_id || ''}:${auth.activeTenant || ''}`)
@@ -42,14 +43,24 @@ const decoratedRooms = computed(() => [
]) ])
const visibleRooms = computed(() => decoratedRooms.value.filter(room => !hiddenRoomKeys.value.includes(room.key))) 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 hiddenRooms = computed(() => decoratedRooms.value.filter(room => hiddenRoomKeys.value.includes(room.key)))
const normalizeRoomSearch = value => String(value || '').toLowerCase().normalize('NFKD').replace(/[\u0300-\u036f]/g, '').replace(/ß/g, 'ss')
const roomSearchTerms = computed(() => normalizeRoomSearch(roomSearch.value).trim().split(/\s+/).filter(Boolean))
function matchesRoomSearch(room) {
const text = normalizeRoomSearch([room.name, room.topic, room.projectNumber, room.email, room.ownerName].filter(value => value != null).join(' '))
return roomSearchTerms.value.every(term => text.includes(term))
}
// Filter only the sidebar; the open conversation and its draft keep their selection.
const filteredRooms = computed(() => visibleRooms.value.filter(matchesRoomSearch))
const filteredHiddenRooms = computed(() => hiddenRooms.value.filter(matchesRoomSearch))
const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit'] const groupedRooms = computed(() => ['Räume', 'Projekte', 'Direktnachrichten', 'Zusammenarbeit']
.map(label => ({ label, rooms: visibleRooms.value.filter(room => room.group === label) })) .map(label => ({ label, rooms: filteredRooms.value.filter(room => room.group === label) }))
.filter(group => group.rooms.length)) .filter(group => group.rooms.length))
const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null) const activeRoom = computed(() => visibleRooms.value.find(room => room.key === activeRoomKey.value) || null)
const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration') const isCollaboration = computed(() => activeRoom.value?.source === 'collaboration')
const isInternal = computed(() => activeRoom.value?.source === 'internal') const isInternal = computed(() => activeRoom.value?.source === 'internal')
const { draft, draftImage, draftSending, clearDraft, startDraftSend, finishDraftSend } = useChatDraft(() => isInternal.value ? activeRoomKey.value : '')
function clearMessages() { messages.value = []; members.value = []; draft.value = ''; draftImage.value = null; syncAfterId = 0; roomLoading.value = false; sending.value = false } function clearMessages() { messages.value = []; members.value = []; syncAfterId = 0; roomLoading.value = false; sending.value = false }
function mergeMessages(incoming) { function mergeMessages(incoming) {
const byId = new Map(messages.value.map(message => [message.id, message])) const byId = new Map(messages.value.map(message => [message.id, message]))
for (const message of incoming || []) byId.set(message.id, message) for (const message of incoming || []) byId.set(message.id, message)
@@ -210,14 +221,17 @@ async function respondToInvitation(id, accept) {
async function sendMessage() { async function sendMessage() {
const text = draft.value.trim(), image = draftImage.value, request = selection() const text = draft.value.trim(), image = draftImage.value, request = selection()
if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return if ((!text && !image) || sending.value || !isInternal.value || !activeRoom.value?.exists) return
sending.value = true; draft.value = '' const savedDraft = startDraftSend()
if (!savedDraft) return
sending.value = true
try { try {
const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } }) const message = await $api(nativeEndpoint(request.key, '/messages'), { method: 'POST', body: { text, ...(image ? { image } : {}) } })
clearDraft(savedDraft)
if (!currentSelection(request)) return if (!currentSelection(request)) return
draftImage.value = null; mergeMessages([message]); await markRead(request); await scrollToBottom(request) mergeMessages([message]); await markRead(request); await scrollToBottom(request)
} catch (error) { } catch (error) {
if (currentSelection(request)) { draft.value = text; toast.add({ title: errorMessage(error), color: 'error' }) } if (currentSelection(request)) toast.add({ title: errorMessage(error), color: 'error' })
} finally { if (currentSelection(request)) sending.value = false } } finally { finishDraftSend(savedDraft); if (currentSelection(request)) sending.value = false }
} }
async function pollMessages() { async function pollMessages() {
if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return if (pollBusy || loading.value || roomLoading.value || document.hidden || !isInternal.value || !activeRoom.value?.exists) return
@@ -242,6 +256,7 @@ async function initialize() {
baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = [] baseRooms.value = []; projectRooms.value = []; directRooms.value = []; collaborationRooms.value = []; invitations.value = []
unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false unreadRooms.value = {}; invitationBusy.value = ''; collaborationError.value = ''; listError.value = ''; creating.value = false
hiddenRoomKeys.value = []; showHiddenRooms.value = false; preferenceBusy.value = ''; preferenceError.value = ''; preferencesReady.value = false hiddenRoomKeys.value = []; showHiddenRooms.value = false; preferenceBusy.value = ''; preferenceError.value = ''; preferencesReady.value = false
roomSearch.value = ''
showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' }) showRoomForm.value = false; Object.assign(roomForm, { name: '', key: '', topic: '' })
activeRoomKey.value = requestedRoom(); loading.value = true activeRoomKey.value = requestedRoom(); loading.value = true
await Promise.all([refreshLists(), loadRoomPreferences(scope)]) await Promise.all([refreshLists(), loadRoomPreferences(scope)])
@@ -287,6 +302,12 @@ onBeforeUnmount(() => {
<UButton icon="i-heroicons-plus" color="neutral" variant="ghost" size="sm" @click="showRoomForm = !showRoomForm" /> <UButton icon="i-heroicons-plus" color="neutral" variant="ghost" size="sm" @click="showRoomForm = !showRoomForm" />
</div> </div>
<UInput v-model="roomSearch" icon="i-heroicons-magnifying-glass" placeholder="Chats suchen …" aria-label="Chats suchen" class="mb-4 w-full" @keydown.esc="roomSearch = ''">
<template v-if="roomSearch" #trailing>
<UButton icon="i-heroicons-x-mark" color="neutral" variant="link" size="xs" aria-label="Chatsuche löschen" @click="roomSearch = ''" />
</template>
</UInput>
<form v-if="showRoomForm" class="mb-4 space-y-2 rounded-lg bg-gray-50 p-3" @submit.prevent="createRoom"> <form v-if="showRoomForm" class="mb-4 space-y-2 rounded-lg bg-gray-50 p-3" @submit.prevent="createRoom">
<UInput v-model="roomForm.name" placeholder="Raumname" autofocus /> <UInput v-model="roomForm.name" placeholder="Raumname" autofocus />
<UInput v-model="roomForm.key" :placeholder="roomKeyPreview || 'raumschluessel'" /> <UInput v-model="roomForm.key" :placeholder="roomKeyPreview || 'raumschluessel'" />
@@ -339,13 +360,14 @@ onBeforeUnmount(() => {
<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)" /> <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> </div>
</section> </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> <p v-if="roomSearchTerms.length && !filteredRooms.length && !filteredHiddenRooms.length" role="status" class="px-2 text-sm text-gray-500">Keine Chats für diese Suche gefunden.</p>
<section v-if="hiddenRooms.length" aria-label="Ausgeblendete Räume"> <p v-else-if="!roomSearchTerms.length && !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="filteredHiddenRooms.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"> <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'" /> <span>Ausgeblendete Räume ({{ filteredHiddenRooms.length }})</span><UIcon :name="showHiddenRooms ? 'i-heroicons-chevron-up' : 'i-heroicons-chevron-down'" />
</button> </button>
<div v-if="showHiddenRooms" class="space-y-1"> <div v-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"> <div v-for="room in filteredHiddenRooms" :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> <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> <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>
@@ -386,7 +408,7 @@ onBeforeUnmount(() => {
</ChatReactions> </ChatReactions>
</div> </div>
<ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" /> <ChatComposer :key="`${session}:${activeRoomKey}`" v-model="draft" v-model:image="draftImage" :busy="sending || draftSending" :disabled="!activeRoom?.exists || roomLoading" @send="sendMessage" />
</main> </main>
<aside v-if="!isCollaboration" 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">

View File

@@ -104,10 +104,36 @@ const bankaccounts = ref([])
const texttemplates = ref([]) const texttemplates = ref([])
const units = ref([]) const units = ref([])
const tenantUsers = ref([]) const tenantUsers = ref([])
const documentTypeItems = computed(() => Object.keys(dataStore.documentTypesForCreation).map((key) => ({ const originalDocumentType = ref(null)
label: dataStore.documentTypesForCreation[key].labelSingle, const canUseAdvanceInvoice = computed(() => originalDocumentType.value === 'advanceInvoices' || (
type: key route.query.type === 'advanceInvoices'
}))) && route.query.loadMode === 'advanceInvoice'
&& Number.isSafeInteger(Number(route.query.project))
&& Number(route.query.project) > 0
&& !route.params.id
&& !route.query.createddocument
&& !route.query.optionsToImport
&& !route.query.linkedDocuments
&& !route.query.templateId
&& !isTemplateMode.value
))
const documentTypeItems = computed(() => Object.keys(dataStore.documentTypesForCreation)
.filter(key => key !== 'advanceInvoices' || canUseAdvanceInvoice.value)
.map((key) => ({
label: dataStore.documentTypesForCreation[key].labelSingle,
type: key
})))
const validateAdvanceInvoiceCreation = () => {
if (itemInfo.value.type !== 'advanceInvoices' || isTemplateMode.value) return true
if (canUseAdvanceInvoice.value && (route.params.id || normalizeEntityId(itemInfo.value.project))) return true
toast.add({
title: 'Abschlagsrechnung nur im Projekt erstellen',
description: 'Bitte den Button „Abschlagsrechnung“ innerhalb eines Projekts verwenden.',
color: 'error'
})
return false
}
const taxTypeItems = [ const taxTypeItems = [
{ key: 'Standard', label: 'Standard' }, { key: 'Standard', label: 'Standard' },
{ key: '13b UStG', label: '13b UStG' }, { key: '13b UStG', label: '13b UStG' },
@@ -391,6 +417,7 @@ const setupPage = async () => {
if (route.params.id) { if (route.params.id) {
console.log(route.params) console.log(route.params)
itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,'',false) itemInfo.value = await useEntities("createddocuments").selectSingle(route.params.id,'',false)
originalDocumentType.value = itemInfo.value.type
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows) itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
itemInfo.value.taxType = normalizeTaxTypeValue(itemInfo.value.taxType) itemInfo.value.taxType = normalizeTaxTypeValue(itemInfo.value.taxType)
await setContactPersonData() await setContactPersonData()
@@ -699,6 +726,16 @@ const setupPage = async () => {
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows) itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
if (!validateAdvanceInvoiceCreation()) {
await router.replace('/createDocument')
return
}
if (route.query.regeneratePdf && route.params.id) {
await regenerateMissingPdf()
return
}
loaded.value = true loaded.value = true
} }
@@ -1817,6 +1854,7 @@ const saveDocumentTemplate = async () => {
} }
const saveDocument = async (state, resetup = false) => { const saveDocument = async (state, resetup = false) => {
if (!validateAdvanceInvoiceCreation()) return
itemInfo.value.state = state itemInfo.value.state = state
normalizeCustomSurchargePercentage() normalizeCustomSurchargePercentage()
@@ -1948,38 +1986,7 @@ const closeDocument = async () => {
await saveDocument("Gebucht") await saveDocument("Gebucht")
await generateDocument() await uploadGeneratedDocument()
let fileData = {}
fileData.project = itemInfo.value.project
fileData.createddocument = itemInfo.value.id
let mappedType = documentStorageFallbackTypes[itemInfo.value.type] || itemInfo.value.type
const folders = await useEntities("folders").select()
console.log(folders)
fileData.folder = folders.find(i => i.function === mappedType && i.year === Number(dayjs().format("YYYY"))).id
const tags = await useEntities("filetags").select()
console.log(tags)
fileData.type = tags.find(i => i.createdDocumentType === mappedType).id
function dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(","),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[arr.length - 1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, {type: mime});
}
let file = dataURLtoFile(uri.value, `${itemInfo.value.documentNumber}.pdf`)
await useFiles().uploadFiles(fileData, [file])
guard.value = false guard.value = false
@@ -1989,6 +1996,77 @@ const closeDocument = async () => {
}
// Erzeugt die PDF aus dem aktuellen Stand und hängt sie an den Beleg an, ohne den Datensatz zu speichern
const uploadGeneratedDocument = async () => {
await generateDocument()
if (!uri.value) throw new Error("PDF konnte nicht erzeugt werden")
let fileData = {}
fileData.project = itemInfo.value.project
fileData.createddocument = itemInfo.value.id
let mappedType = documentStorageFallbackTypes[itemInfo.value.type] || itemInfo.value.type
const folders = await useEntities("folders").select()
console.log(folders)
fileData.folder = folders.find(i => i.function === mappedType && i.year === Number(dayjs().format("YYYY"))).id
const tags = await useEntities("filetags").select()
console.log(tags)
fileData.type = tags.find(i => i.createdDocumentType === mappedType).id
function dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(","),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[arr.length - 1]),
n = bstr.length,
u8arr = new Uint8Array(n);
while (n--) {
u8arr[n] = bstr.charCodeAt(n);
}
return new File([u8arr], filename, {type: mime});
}
let file = dataURLtoFile(uri.value, `${itemInfo.value.documentNumber}.pdf`)
await useFiles().uploadFiles(fileData, [file])
}
const regenerateMissingPdf = async () => {
guard.value = false
const showPath = `/createDocument/show/${route.params.id}`
try {
const document = await useEntities("createddocuments").selectSingle(route.params.id, "*,files(*)")
if (document?.state === "Entwurf" || document?.type === "serialInvoices") {
toast.add({ title: "PDF kann nur für fertiggestellte Belege nacherzeugt werden", color: "error" })
} else if ((document?.files || []).length > 0) {
toast.add({ title: "Am Beleg ist bereits ein Dokument angehängt", color: "warning" })
} else {
// Vertrags- und Mandats-Watcher überschreiben beim Laden die Zahlungsdaten, für die PDF gilt der gespeicherte Stand
const restoreStoredPaymentData = () => {
itemInfo.value.payment_type = document.payment_type
itemInfo.value.outgoingsepamandate = normalizeEntityId(document.outgoingsepamandate)
}
await nextTick()
restoreStoredPaymentData()
await nextTick()
restoreStoredPaymentData()
await uploadGeneratedDocument()
toast.add({ title: "PDF nacherzeugt", color: "success" })
}
} catch (error) {
console.error(error)
toast.add({ title: "PDF konnte nicht nacherzeugt werden", description: error?.data?.error || error?.message, color: "error" })
}
await router.replace(showPath)
} }
const getTextTemplateByType = (type, pos) => { const getTextTemplateByType = (type, pos) => {

View File

@@ -110,10 +110,18 @@ const setupPage = async () => {
links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id) links.value = createddocuments.filter(i => i.createddocument?.id === itemInfo.value.id)
} }
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id) if (itemInfo.value.files?.length > 0) {
linkedDocument.value = await useFiles().selectDocument(itemInfo.value.files[0].id)
}
} }
} }
const canRegeneratePdf = computed(() => itemInfo.value.id
&& itemInfo.value.state !== 'Entwurf'
&& itemInfo.value.type !== 'serialInvoices'
&& Array.isArray(itemInfo.value.files)
&& itemInfo.value.files.length === 0)
setupPage() setupPage()
const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null) const getStatementLike = (allocation) => allocation?.bankstatement || (typeof allocation?.bs_id === "object" ? allocation.bs_id : null)
@@ -282,6 +290,12 @@ const togglePortalRelease = async () => {
:loading="costCentresLoading" :loading="costCentresLoading"
@click="openCostCentres" @click="openCostCentres"
>Kostenstellen bearbeiten</UButton> >Kostenstellen bearbeiten</UButton>
<UButton
v-if="canRegeneratePdf"
icon="i-heroicons-document-arrow-down"
variant="outline"
@click="router.push(`/createDocument/edit/${itemInfo.id}?regeneratePdf=1`)"
>PDF nacherzeugen</UButton>
<!-- <UButton <!-- <UButton
:to="dataStore.documents.find(i => i.createdDocument === itemInfo.id) ? dataStore.documents.find(i => i.createdDocument === itemInfo.id).url : ''" :to="dataStore.documents.find(i => i.createdDocument === itemInfo.id) ? dataStore.documents.find(i => i.createdDocument === itemInfo.id).url : ''"
target="_blank" target="_blank"

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import dayjs from "dayjs" import dayjs from "dayjs"
import { getPortalColor, portalThemeStyle } from "~/utils/portalTheme"
definePageMeta({ definePageMeta({
layout: "notLoggedIn" layout: "notLoggedIn"
@@ -7,6 +8,7 @@ definePageMeta({
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
const portalTheme = computed(() => portalThemeStyle(getPortalColor(auth.activeTenantData?.portalConfig)))
const customer = ref<any | null>(null) const customer = ref<any | null>(null)
const contracts = ref<any[]>([]) const contracts = ref<any[]>([])
@@ -337,7 +339,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div class="min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100"> <div :style="portalTheme" class="min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100">
<UContainer class="py-8 lg:py-12"> <UContainer class="py-8 lg:py-12">
<div class="flex flex-col gap-6"> <div class="flex flex-col gap-6">
<div class="rounded-3xl border border-neutral-200 bg-white/90 p-6 shadow-sm lg:p-8"> <div class="rounded-3xl border border-neutral-200 bg-white/90 p-6 shadow-sm lg:p-8">
@@ -392,7 +394,7 @@ onMounted(async () => {
</div> </div>
<UCard v-else :ui="{ body: 'p-4 sm:p-6' }"> <UCard v-else :ui="{ body: 'p-4 sm:p-6' }">
<UTabs v-model="activeTab" :items="portalTabs" class="portal-tabs"> <UTabs v-model="activeTab" :items="portalTabs" :ui="{ trigger: 'data-[state=active]:text-[var(--portal-accent-contrast)]' }" class="portal-tabs">
<template #customer> <template #customer>
<div class="pt-4"> <div class="pt-4">
<div class="mb-4 flex items-center justify-between gap-4"> <div class="mb-4 flex items-center justify-between gap-4">
@@ -470,7 +472,7 @@ onMounted(async () => {
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<UButton icon="i-heroicons-check" :loading="saving" @click="saveCustomerData"> <UButton icon="i-heroicons-check" class="text-[var(--portal-accent-contrast)]" :loading="saving" @click="saveCustomerData">
Kundendaten speichern Kundendaten speichern
</UButton> </UButton>
</div> </div>
@@ -770,7 +772,7 @@ onMounted(async () => {
</div> </div>
</UContainer> </UContainer>
<UModal v-model:open="contractChangeModalOpen"> <UModal v-model:open="contractChangeModalOpen" :content="{ style: portalTheme }">
<template #content> <template #content>
<div class="space-y-5 p-6"> <div class="space-y-5 p-6">
<div> <div>
@@ -787,6 +789,7 @@ onMounted(async () => {
<USelectMenu <USelectMenu
v-model="contractChangeForm.contracttype" v-model="contractChangeForm.contracttype"
:items="selectedContractAllowedContracttypes" :items="selectedContractAllowedContracttypes"
:content="{ style: portalTheme }"
value-key="id" value-key="id"
label-key="name" label-key="name"
class="w-full" class="w-full"
@@ -813,6 +816,7 @@ onMounted(async () => {
</UButton> </UButton>
<UButton <UButton
color="primary" color="primary"
class="text-[var(--portal-accent-contrast)]"
:loading="submittingContractRequest" :loading="submittingContractRequest"
:disabled="!contractChangeForm.contracttype" :disabled="!contractChangeForm.contracttype"
@click="submitContractChangeRequest" @click="submitContractChangeRequest"
@@ -824,7 +828,7 @@ onMounted(async () => {
</template> </template>
</UModal> </UModal>
<UModal v-model:open="cancellationModalOpen"> <UModal v-model:open="cancellationModalOpen" :content="{ style: portalTheme }">
<template #content> <template #content>
<div class="space-y-5 p-6"> <div class="space-y-5 p-6">
<div> <div>

View File

@@ -0,0 +1,206 @@
<script setup>
import dayjs from 'dayjs'
definePageMeta({ alias: ['/standardEntity/events'] })
useHead({ title: 'Termine · FEDEO' })
const auth = useAuthStore()
const { has } = usePermission()
const eventEntities = useEntities('events')
const canView = computed(() => has('events') && auth.activeTenantData?.features?.calendar !== false)
const canCreate = computed(() => canView.value && has('events-create'))
const events = ref([])
const total = ref(0)
const loading = ref(false)
const error = ref('')
const search = ref('')
const status = ref('all')
const page = ref(1)
const pageSize = 25
const sortDirection = ref('desc')
const createRoute = { path: '/standardEntity/events/create', query: { returnTo: 'events' } }
const statusOptions = [
{ label: 'Alle Status', value: 'all' },
{ label: 'Entwurf', value: 'Entwurf' },
{ label: 'Final', value: 'Final' }
]
const sortOptions = [
{ label: 'Beginn: neueste zuerst', value: 'desc' },
{ label: 'Beginn: älteste zuerst', value: 'asc' }
]
const columns = [
{ accessorKey: 'name', header: 'Termin' },
{ accessorKey: 'startDate', header: 'Zeitraum' },
{ accessorKey: 'project', header: 'Projekt' },
{ accessorKey: 'state', header: 'Status' },
{ id: 'actions', header: '' }
]
let requestVersion = 0
let searchTimer
const editRoute = (event) => ({
path: `/standardEntity/events/edit/${event.id}`,
query: { returnTo: 'events' }
})
const eventTitle = (event) => event.name || event.project?.name || 'Termin ohne Titel'
const projectLabel = (event) => {
const project = event.project
if (!project) return 'Ohne Projekt'
if (typeof project !== 'object') return `Projekt ${project}`
return [project.projectNumber, project.name].filter(Boolean).join(' · ') || 'Ohne Projekt'
}
const formatDate = (value) => value && dayjs(value).isValid() ? dayjs(value).format('DD.MM.YYYY HH:mm') : 'Nicht angegeben'
const eventColor = (event) => event.color
|| (event.quick ? auth.activeTenantData?.calendarConfig?.quickEntry?.color : null)
|| auth.activeTenantData?.calendarConfig?.eventTypes?.find((type) => type.label === event.eventtype)?.color
|| '#2563eb'
async function loadEvents() {
const version = ++requestVersion
if (!canView.value) {
events.value = []
total.value = 0
loading.value = false
return
}
loading.value = true
error.value = ''
try {
const result = await eventEntities.selectPaginated({
select: '*, project(id,name,projectNumber)',
filters: { archived: false, ...(status.value === 'all' ? {} : { state: status.value }) },
search: search.value.trim(),
sort: [{ field: 'startDate', direction: sortDirection.value }],
page: page.value,
limit: pageSize
})
if (version !== requestVersion) return
events.value = result.data
total.value = result.meta?.total || 0
} catch {
if (version !== requestVersion) return
events.value = []
total.value = 0
error.value = 'Die Termine konnten nicht geladen werden. Bitte versuche es erneut.'
} finally {
if (version === requestVersion) loading.value = false
}
}
function reloadFromFirstPage() {
clearTimeout(searchTimer)
if (page.value !== 1) page.value = 1
else loadEvents()
}
function resetFilters() {
search.value = ''
status.value = 'all'
}
watch(search, () => {
++requestVersion
loading.value = true
clearTimeout(searchTimer)
searchTimer = setTimeout(reloadFromFirstPage, 250)
})
watch([status, sortDirection, canView, () => auth.activeTenant], reloadFromFirstPage)
watch(page, loadEvents)
onBeforeUnmount(() => {
clearTimeout(searchTimer)
++requestVersion
})
loadEvents()
</script>
<template>
<UDashboardNavbar title="Termine" :badge="total">
<template #right>
<UButton v-if="canCreate" :to="createRoute" icon="i-heroicons-plus" label="Neuer Termin" />
</template>
</UDashboardNavbar>
<UDashboardPanelContent class="space-y-4">
<UAlert v-if="!canView" color="neutral" variant="subtle" title="Termine sind für deinen Zugang nicht verfügbar." />
<template v-else>
<div class="flex flex-col gap-3 lg:flex-row lg:items-center">
<UInput
v-model="search"
icon="i-heroicons-magnifying-glass"
placeholder="Termin, Projekt oder Projektnummer suchen …"
aria-label="Termine durchsuchen"
class="w-full lg:flex-1"
/>
<div class="flex flex-wrap gap-2">
<USelect v-model="status" :items="statusOptions" aria-label="Status filtern" class="min-w-36 flex-1 lg:flex-none" />
<USelect v-model="sortDirection" :items="sortOptions" aria-label="Termine sortieren" class="min-w-48 flex-1 lg:flex-none" />
<UButton color="neutral" variant="outline" icon="i-heroicons-arrow-path" aria-label="Termine aktualisieren" :loading="loading" @click="loadEvents" />
</div>
</div>
<UAlert v-if="error" color="error" variant="subtle" title="Laden fehlgeschlagen" :description="error">
<template #actions>
<UButton color="error" variant="outline" label="Erneut versuchen" @click="loadEvents" />
</template>
</UAlert>
<div v-else-if="loading" class="space-y-3 py-4" role="status" aria-label="Termine werden geladen">
<USkeleton v-for="index in 5" :key="index" class="h-20 w-full rounded-lg" />
</div>
<div v-else-if="!events.length" class="rounded-xl border border-dashed border-default px-4 py-14 text-center">
<UIcon name="i-heroicons-calendar-days" class="mx-auto mb-3 size-10 text-muted" />
<h2 class="text-lg font-semibold">{{ search || status !== 'all' ? 'Keine passenden Termine' : 'Noch keine Termine' }}</h2>
<p class="mt-1 text-sm text-muted">{{ search || status !== 'all' ? 'Passe deine Suche oder den Statusfilter an.' : 'Erstelle deinen ersten Termin direkt über diese Übersicht.' }}</p>
<UButton v-if="search || status !== 'all'" class="mt-4" color="neutral" variant="outline" label="Filter zurücksetzen" @click="resetFilters" />
<UButton v-else-if="canCreate" class="mt-4" :to="createRoute" icon="i-heroicons-plus" label="Neuer Termin" />
</div>
<template v-else>
<UTable :data="events" :columns="columns" class="hidden rounded-xl border border-default md:block" :ui="{ td: 'align-top' }">
<template #name-cell="{ row }">
<div class="flex items-start gap-3">
<span class="mt-1 size-3 shrink-0 rounded-full" :style="{ backgroundColor: eventColor(row.original) }" aria-hidden="true" />
<div class="min-w-0">
<NuxtLink :to="editRoute(row.original)" class="block max-w-sm whitespace-normal break-words font-medium text-highlighted hover:text-primary">{{ eventTitle(row.original) }}</NuxtLink>
<p v-if="row.original.repeatInterval && row.original.repeatInterval !== 'Keine Wiederholung'" class="mt-1 text-xs text-muted">{{ row.original.repeatInterval }}</p>
<p v-if="row.original.quick" class="mt-1 text-xs text-muted">Quick-Eintrag</p>
</div>
</div>
</template>
<template #startDate-cell="{ row }">
<div>{{ formatDate(row.original.startDate) }}</div>
<div v-if="row.original.endDate" class="mt-1 text-xs text-muted">bis {{ formatDate(row.original.endDate) }}</div>
</template>
<template #project-cell="{ row }">
<span class="block max-w-xs whitespace-normal break-words">{{ projectLabel(row.original) }}</span>
</template>
<template #state-cell="{ row }">
<UBadge :color="row.original.state === 'Entwurf' ? 'warning' : 'success'" variant="subtle">{{ row.original.state || 'Final' }}</UBadge>
</template>
<template #actions-cell="{ row }">
<UButton :to="editRoute(row.original)" color="neutral" variant="ghost" icon="i-heroicons-pencil-square" :aria-label="`${eventTitle(row.original)} bearbeiten`" />
</template>
</UTable>
<div class="space-y-3 md:hidden">
<NuxtLink v-for="event in events" :key="event.id" :to="editRoute(event)" class="block rounded-xl border border-default p-4 hover:border-primary">
<div class="flex items-start gap-3">
<span class="mt-1 size-3 shrink-0 rounded-full" :style="{ backgroundColor: eventColor(event) }" aria-hidden="true" />
<h2 class="min-w-0 flex-1 break-words font-semibold text-highlighted">{{ eventTitle(event) }}</h2>
<UBadge :color="event.state === 'Entwurf' ? 'warning' : 'success'" variant="subtle" class="shrink-0">{{ event.state || 'Final' }}</UBadge>
</div>
<p class="mt-3 text-sm">{{ formatDate(event.startDate) }}</p>
<p v-if="event.endDate" class="text-sm text-muted">bis {{ formatDate(event.endDate) }}</p>
<p class="mt-2 break-words text-sm text-muted">{{ projectLabel(event) }}</p>
<p v-if="event.repeatInterval && event.repeatInterval !== 'Keine Wiederholung'" class="mt-2 text-xs text-muted">{{ event.repeatInterval }}</p>
<p v-if="event.quick" class="mt-1 text-xs text-muted">Quick-Eintrag</p>
</NuxtLink>
</div>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-default pt-4">
<p class="text-sm text-muted">{{ total }} {{ total === 1 ? 'Termin' : 'Termine' }}</p>
<UPagination v-model:page="page" :total="total" :items-per-page="pageSize" :sibling-count="0" />
</div>
</template>
</template>
</UDashboardPanelContent>
</template>

View File

@@ -200,6 +200,47 @@ const getAccountLabel = (item) => {
return account ? `${account.number || ""} ${account.label || ""}`.trim() : "-" return account ? `${account.number || ""} ${account.label || ""}`.trim() : "-"
} }
const getCostCentreLabel = (item) => costcentres.value.find((costcentre) => costcentre.id === item.costCentre)?.name || "-" const getCostCentreLabel = (item) => costcentres.value.find((costcentre) => costcentre.id === item.costCentre)?.name || "-"
const canEditCostCentres = computed(() => mode.value === "show" && Boolean(itemInfo.value.id) && !itemInfo.value.archived)
const costCentresOpen = ref(false)
const costCentresSaving = ref(false)
const costCentreDraft = ref([])
const costCentreOptionLabel = (id) => {
const centre = costcentres.value.find((item) => item.id === id)
return centre ? [centre.number, centre.name].filter(Boolean).join(" - ") : (id ? "Bisherige Kostenstelle" : "Keine Kostenstelle")
}
const openCostCentres = () => {
costCentreDraft.value = (itemInfo.value.accounts || []).map((account, index) => ({
index,
label: `${index + 1}. ${account.description || getAccountLabel(account)}`,
costcentre: account.costCentre || null,
}))
costCentresOpen.value = true
}
const saveCostCentres = async () => {
if (costCentresSaving.value) return
costCentresSaving.value = true
try {
const accountCostCentres = costCentreDraft.value
.filter((row) => row.costcentre !== (itemInfo.value.accounts[row.index]?.costCentre || null))
.map(({ index, costcentre }) => ({ index, costcentre }))
if (accountCostCentres.length) {
const { invoice } = await useNuxtApp().$api(`/api/functions/incoming-invoices/${itemInfo.value.id}/cost-centres`, {
method: "PUT",
body: { accountCostCentres },
})
itemInfo.value.accounts = normalizeIncomingInvoiceAccounts(invoice.accounts || [], invoice.date)
toast.add({ title: "Kostenstellen gespeichert", color: "success" })
}
costCentresOpen.value = false
} catch (error) {
toast.add({ title: "Kostenstellen konnten nicht gespeichert werden", description: error?.data?.error || "Bitte versuche es erneut.", color: "error" })
} finally {
costCentresSaving.value = false
}
}
const getBookingModeLabel = (item) => EXPENSE_BOOKING_MODE_ITEMS.find((mode) => mode.value === item.bookingMode)?.label || "-" const getBookingModeLabel = (item) => EXPENSE_BOOKING_MODE_ITEMS.find((mode) => mode.value === item.bookingMode)?.label || "-"
const getTaxLabel = (item) => taxOptions.value.find((tax) => tax.key === item.taxType)?.label || "-" const getTaxLabel = (item) => taxOptions.value.find((tax) => tax.key === item.taxType)?.label || "-"
@@ -362,6 +403,14 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
> >
Bearbeiten Bearbeiten
</UButton> </UButton>
<UButton
v-if="canEditCostCentres"
icon="i-heroicons-tag"
variant="outline"
@click="openCostCentres"
>
Kostenstellen bearbeiten
</UButton>
<ArchiveButton <ArchiveButton
v-if="canArchiveIncomingInvoice" v-if="canArchiveIncomingInvoice"
color="error" color="error"
@@ -1060,6 +1109,27 @@ const hasBlockingIncomingInvoiceErrors = computed(() => blockingIncomingInvoiceE
</div> </div>
</div> </div>
<UModal v-model:open="costCentresOpen" title="Kostenstellen bearbeiten" :dismissible="!costCentresSaving" :close="!costCentresSaving" :ui="{ content: 'sm:max-w-2xl' }">
<template #body>
<p class="mb-4 text-sm text-muted">Die Kostenstellen der Positionen können jederzeit geändert werden, auch bei gebuchten oder zugewiesenen Belegen. Alle anderen Angaben bleiben unverändert.</p>
<div class="space-y-4">
<UFormField v-for="row in costCentreDraft" :key="row.index" :label="row.label">
<InputGroup>
<USelectMenu v-model="row.costcentre" :items="costcentres" value-key="id" label-key="name" :filter-fields="['name', 'number']" class="w-full" :disabled="costCentresSaving">
<template #default>{{ costCentreOptionLabel(row.costcentre) }}</template>
<template #item="{ item }">{{ [item.number, item.name].filter(Boolean).join(' - ') }}</template>
</USelectMenu>
<UButton icon="i-heroicons-x-mark" variant="outline" :aria-label="`Kostenstelle für ${row.label} entfernen`" :disabled="costCentresSaving || !row.costcentre" @click="row.costcentre = null" />
</InputGroup>
</UFormField>
</div>
</template>
<template #footer>
<UButton variant="outline" :disabled="costCentresSaving" @click="costCentresOpen = false">Abbrechen</UButton>
<UButton :loading="costCentresSaving" @click="saveCostCentres">Kostenstellen speichern</UButton>
</template>
</UModal>
</template> </template>
<style scoped> <style scoped>

View File

@@ -64,35 +64,89 @@ const setupPage = async () => {
items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc") items.value = await useEntities(type).select("*, vendor(id,name), statementallocations(id,amount)",sort.value.column,sort.value.direction === "asc")
} }
// Funktion zum Vorbereiten der Belege // Die Belegvorbereitung läuft im Backend im Hintergrund; hier wird nur der Status abgefragt.
const prepareInvoices = async () => { const preparationEndpoint = '/api/functions/services/prepareincominginvoices'
isPreparing.value = true let preparationPollTimer = null
try {
await $api('/api/functions/services/prepareincominginvoices', { method: 'POST' })
toast.add({ const showPreparationResult = (status) => {
title: 'Erfolg', const result = status?.result
description: 'Eingangsbelege wurden vorbereitet.', if (result?.error && !result.created?.length) {
icon: 'i-heroicons-check-circle',
color: 'green'
})
// Liste neu laden
await setupPage()
} catch (error) {
console.error(error)
toast.add({ toast.add({
title: 'Fehler', title: 'Fehler',
description: 'Beim Vorbereiten der Belege ist ein Fehler aufgetreten.', description: result.error,
icon: 'i-heroicons-exclamation-circle', icon: 'i-heroicons-exclamation-circle',
color: 'red' color: 'red'
}) })
} finally { return
}
const created = result?.created?.length || 0
const skipped = result?.skipped?.length || 0
toast.add({
title: 'Belegvorbereitung abgeschlossen',
description: `${created} Eingangsbeleg(e) vorbereitet${skipped ? `, ${skipped} Datei(en) übersprungen` : ''}.`,
icon: 'i-heroicons-check-circle',
color: 'green'
})
}
const pollPreparation = (notify) => {
clearTimeout(preparationPollTimer)
preparationPollTimer = setTimeout(async () => {
try {
const status = await $api(preparationEndpoint)
if (status?.running) {
pollPreparation(notify)
return
}
isPreparing.value = false
if (notify) showPreparationResult(status)
await setupPage()
} catch (error) {
console.error(error)
pollPreparation(notify)
}
}, 3000)
}
const prepareInvoices = async () => {
isPreparing.value = true
try {
await $api(preparationEndpoint, { method: 'POST' })
toast.add({
title: 'Belegvorbereitung gestartet',
description: 'Die Belege werden im Hintergrund vorbereitet.',
icon: 'i-heroicons-sparkles'
})
pollPreparation(true)
} catch (error) {
console.error(error)
isPreparing.value = false isPreparing.value = false
toast.add({
title: 'Fehler',
description: 'Die Belegvorbereitung konnte nicht gestartet werden.',
icon: 'i-heroicons-exclamation-circle',
color: 'red'
})
} }
} }
const resumePreparationStatus = async () => {
try {
const status = await $api(preparationEndpoint)
if (status?.running) {
isPreparing.value = true
pollPreparation(true)
}
} catch (error) {
console.error(error)
}
}
onBeforeUnmount(() => clearTimeout(preparationPollTimer))
setupPage() setupPage()
resumePreparationStatus()
const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable)) const selectedColumns = ref(tempStore.columns[type] ? [...tempStore.columns[type]] : dataType.templateColumns.filter(i => !i.disabledInTable))
if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) { if (!selectedColumns.value.find((column) => column.key === openAmountColumnKey)) {

View File

@@ -7,7 +7,6 @@ definePageMeta({
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
const router = useRouter()
const state = reactive({ const state = reactive({
email: '', email: '',
@@ -22,7 +21,6 @@ const doLogin = async (event: FormSubmitEvent<typeof state>) => {
try { try {
await auth.login(event.data.email, event.data.password, event.data.rememberMe) await auth.login(event.data.email, event.data.password, event.data.rememberMe)
toast.add({title:"Einloggen erfolgreich"}) toast.add({title:"Einloggen erfolgreich"})
await router.push("/")
} catch (err: any) { } catch (err: any) {
toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"error"}) toast.add({title:"Zugangsdaten falsch. Bitte überprüfen Sie Ihre Eingaben",color:"error"})
} finally { } finally {

View File

@@ -27,8 +27,8 @@ const doChange = async (event: FormSubmitEvent<typeof state>) => {
}) })
toast.add({title:"Ändern erfolgreich"}) toast.add({title:"Ändern erfolgreich"})
await auth.logout() await auth.fetchMe()
return navigateTo("/login") return navigateTo(auth.getLandingPath(), { replace: true })
} catch (err: any) { } catch (err: any) {
toast.add({title:"Es gab ein Problem beim ändern",color:"error"}) toast.add({title:"Es gab ein Problem beim ändern",color:"error"})
} finally { } finally {

View File

@@ -0,0 +1,9 @@
<template>
<UDashboardNavbar title="Benachrichtigungen" />
<div class="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
<UCard class="mx-auto w-full max-w-4xl">
<NotificationPreferences />
</UCard>
</div>
</template>

View File

@@ -3,6 +3,7 @@ import {
TAX_EVALUATION_PERIOD_OPTIONS, TAX_EVALUATION_PERIOD_OPTIONS,
normalizeTaxEvaluationPeriod normalizeTaxEvaluationPeriod
} from "~/composables/useTaxEvaluation" } from "~/composables/useTaxEvaluation"
import { DEFAULT_PORTAL_COLOR, getPortalColor, normalizePortalColor, portalThemeStyle } from "~/utils/portalTheme"
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
@@ -260,6 +261,16 @@ const setupPage = async () => {
const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) }) const features = ref({ ...defaultFeatures, ...(auth.activeTenantData?.features || {}) })
const businessInfo = ref(normalizeBusinessInfo(auth.activeTenantData?.businessInfo)) const businessInfo = ref(normalizeBusinessInfo(auth.activeTenantData?.businessInfo))
const portalColor = ref(getPortalColor(auth.activeTenantData?.portalConfig))
const portalColorSaving = ref(false)
const validPortalColor = computed(() => normalizePortalColor(portalColor.value))
const portalPreviewStyle = computed(() => portalThemeStyle(portalColor.value))
watch(() => auth.activeTenant, () => {
portalColor.value = getPortalColor(auth.activeTenantData?.portalConfig)
})
watch(() => auth.activeTenantData?.portalConfig, (config) => {
portalColor.value = getPortalColor(config)
})
const accountChart = ref(auth.activeTenantData.accountChart || "skr03") const accountChart = ref(auth.activeTenantData.accountChart || "skr03")
const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod)) const taxEvaluationPeriod = ref(normalizeTaxEvaluationPeriod(auth.activeTenantData.taxEvaluationPeriod))
const accountChartOptions = [ const accountChartOptions = [
@@ -288,6 +299,18 @@ const saveFeatures = async () => {
await updateTenant({features: features.value}) await updateTenant({features: features.value})
} }
const savePortalColor = async () => {
if (!validPortalColor.value || portalColorSaving.value) return
portalColorSaving.value = true
try {
const { primayColor, ...portalConfig } = auth.activeTenantData?.portalConfig || {}
await updateTenant({ portalConfig: { ...portalConfig, primaryColor: validPortalColor.value } })
toast.add({ title: "Akzentfarbe für das Kundenportal gespeichert", color: "success" })
} finally {
portalColorSaving.value = false
}
}
const formatMcpTokenDate = (value) => { const formatMcpTokenDate = (value) => {
if (!value) return "Nie" if (!value) return "Nie"
@@ -614,6 +637,8 @@ onMounted(() => {
label: 'Dokubox' label: 'Dokubox'
},{ },{
label: 'Rechnung & Kontakt' label: 'Rechnung & Kontakt'
},{
label: 'Kundenportal'
},{ },{
label: 'Plantafel' label: 'Plantafel'
},{ },{
@@ -755,6 +780,43 @@ onMounted(() => {
</UCard> </UCard>
</div> </div>
<div v-else-if="item.label === 'Kundenportal'">
<UCard class="mt-5">
<div class="max-w-xl space-y-6">
<div>
<h2 class="text-lg font-semibold">Akzentfarbe im Kundenportal</h2>
<p class="mt-1 text-sm text-muted">Wähle die Farbe für Schaltflächen, Reiter und Hervorhebungen im Kundenportal deiner Firma.</p>
</div>
<UFormField
label="Akzentfarbe"
help="Hex-Farbwert mit sechs Zeichen, zum Beispiel #69c350."
:error="!validPortalColor ? 'Bitte einen gültigen Hex-Farbwert eingeben.' : undefined"
>
<div class="flex items-center gap-3">
<input
type="color"
:value="validPortalColor || DEFAULT_PORTAL_COLOR"
aria-label="Akzentfarbe auswählen"
class="h-10 w-14 shrink-0 cursor-pointer rounded border border-default bg-default p-1"
@input="portalColor = $event.target.value"
>
<UInput v-model="portalColor" aria-label="Akzentfarbe als Hex-Wert" placeholder="#69c350" class="w-40" maxlength="7" />
</div>
</UFormField>
<div :style="portalPreviewStyle" class="rounded-xl border border-default p-5" aria-label="Vorschau der Portal-Akzentfarbe">
<p class="mb-3 text-sm font-medium text-primary-600">Vorschau Kundenportal</p>
<div class="flex flex-wrap items-center gap-3">
<UButton class="text-[var(--portal-accent-contrast)]" tabindex="-1">Kundendaten speichern</UButton>
<UBadge color="primary" variant="subtle">Rechnung</UBadge>
</div>
</div>
<div class="flex flex-wrap gap-3">
<UButton :loading="portalColorSaving" :disabled="!validPortalColor" @click="savePortalColor">Akzentfarbe speichern</UButton>
<UButton color="neutral" variant="outline" :disabled="portalColorSaving" @click="portalColor = DEFAULT_PORTAL_COLOR">FEDEO-Grün verwenden</UButton>
</div>
</div>
</UCard>
</div>
<div v-else-if="item.label === 'Plantafel'"> <div v-else-if="item.label === 'Plantafel'">
<UCard class="mt-5"> <UCard class="mt-5">
<UForm class="w-1/2"> <UForm class="w-1/2">

View File

@@ -31,6 +31,11 @@ export const useAuthStore = defineStore("auth", {
}), }),
actions: { actions: {
getLandingPath() {
if (this.user?.must_change_password) return "/password-change"
return this.profile?.customer_for_portal ? "/customer-portal" : "/"
},
tokenCookie() { tokenCookie() {
return useCookie<string | null>("token", { path: "/" }) return useCookie<string | null>("token", { path: "/" })
}, },
@@ -265,14 +270,14 @@ export const useAuthStore = defineStore("auth", {
const tempStore = useTempStore() const tempStore = useTempStore()
if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config) if(this.profile?.temp_config) tempStore.setStoredTempConfig(this.profile.temp_config)
this.loading = false
if(this.activeTenant > 0) { if(this.activeTenant > 0) {
this.loading = false return navigateTo(this.getLandingPath())
navigateTo("/")
} }
}, },
async login(email: string, password: string, rememberMe = false) { async login(email: string, password: string, rememberMe = false) {
this.loading = true
try { try {
console.log("Auth login") console.log("Auth login")
const { token, refreshToken } = await useNuxtApp().$api("/auth/login", { const { token, refreshToken } = await useNuxtApp().$api("/auth/login", {
@@ -289,21 +294,15 @@ export const useAuthStore = defineStore("auth", {
await this.fetchMe(token) await this.fetchMe(token)
// 3. WICHTIG: Jetzt explizit weiterleiten! if (!this.user) throw new Error("Die Sitzung konnte nicht geladen werden.")
// Prüfen, ob der User geladen wurde this.loading = false
if (this.user) { return await navigateTo(this.getLandingPath(), { replace: true })
// Falls Passwort-Änderung erzwungen wird (passend zu deiner Middleware)
if (this.user.must_change_password) {
return navigateTo("/password-change")
}
// Normaler Login -> Dashboard
return navigateTo("/")
}
} catch (e) { } catch (e) {
console.log("login error:" + e) console.log("login error:" + e)
throw e throw e
} finally {
this.loading = false
} }
}, },

View File

@@ -3231,7 +3231,7 @@ export const useDataStore = defineStore('data', () => {
{ {
key: "color", key: "color",
label: "Farbe", label: "Farbe",
inputType: "text", inputType: "color",
sortable: true sortable: true
}, },
{ {
@@ -3270,7 +3270,7 @@ export const useDataStore = defineStore('data', () => {
inputType: "select", inputType: "select",
selectDataType: "projects", selectDataType: "projects",
selectOptionAttribute: "name", selectOptionAttribute: "name",
selectSearchAttributes: ['name'], selectSearchAttributes: ['name', 'projectNumber'],
},{ },{
key: "vehicles", key: "vehicles",
label: "Fahrzeuge", label: "Fahrzeuge",

View File

@@ -19,6 +19,7 @@ export const useProfileStore = defineStore("profile", () => {
}) })
const profiles = ref([]) const profiles = ref([])
const allProfiles = ref([])
const ownProfiles = ref([]) const ownProfiles = ref([])
const activeProfile = ref(null) const activeProfile = ref(null)
const tenants = ref([]) const tenants = ref([])
@@ -60,8 +61,8 @@ export const useProfileStore = defineStore("profile", () => {
} }
async function fetchData() { async function fetchData() {
await fetchOwnProfiles()
await fetchProfiles() await fetchProfiles()
await fetchOwnProfiles()
await fetchTenants() await fetchTenants()
await fetchOwnTenant() await fetchOwnTenant()
loaded.value = true loaded.value = true
@@ -71,6 +72,7 @@ export const useProfileStore = defineStore("profile", () => {
loaded.value = false loaded.value = false
ownTenant.value = {} ownTenant.value = {}
profiles.value = [] profiles.value = []
allProfiles.value = []
ownProfiles.value = [] ownProfiles.value = []
tenants.value = [] tenants.value = []
} }
@@ -92,10 +94,13 @@ export const useProfileStore = defineStore("profile", () => {
async function fetchProfiles() { async function fetchProfiles() {
try { try {
const res = await useNuxtApp().$api("/api/tenant/profiles") const res = await useNuxtApp().$api("/api/tenant/profiles", { query: { includeArchived: true } })
profiles.value = res?.data || [] // Retain historical names while keeping archived profiles out of selections.
allProfiles.value = res?.data || []
profiles.value = allProfiles.value.filter(profile => profile.active && !profile.archived_at)
} catch (e) { } catch (e) {
profiles.value = activeProfile.value ? [activeProfile.value] : [] profiles.value = activeProfile.value ? [activeProfile.value] : []
allProfiles.value = profiles.value
} }
} }
@@ -112,7 +117,8 @@ export const useProfileStore = defineStore("profile", () => {
}) })
const getProfileById = computed(() => (itemId) => { const getProfileById = computed(() => (itemId) => {
return profiles.value.find((item) => item.id === itemId || item.user_id === itemId) const profile = allProfiles.value.find((item) => item.id === itemId || item.user_id === itemId)
return profile ? { ...profile, fullName: profile.full_name || profile.fullName } : { fullName: "Unbekanntes Profil" }
}) })
return { return {

Some files were not shown because too many files have changed in this diff Show More