26 Commits

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

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
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

@@ -554,13 +554,6 @@
"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
} }
] ]
} }

View File

@@ -82,7 +82,6 @@ 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,7 +13,6 @@ 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

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

View File

@@ -1,44 +0,0 @@
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

@@ -2,14 +2,12 @@ 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"
export const mcpTools = [ export const mcpTools = [
...accountingTools, ...accountingTools,
...masterdataTools, ...masterdataTools,
...organisationTools, ...organisationTools,
...wikiTools, ...wikiTools,
...documentTools,
] ]
export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool])) export const mcpToolMap = new Map(mcpTools.map((tool) => [tool.name, tool]))

View File

@@ -27,7 +27,6 @@ import {
statementAllocationUuidArg, statementAllocationUuidArg,
} from "../statementAllocations" } from "../statementAllocations"
import { McpTool } from "../types" import { McpTool } from "../types"
import { outgoingStatusTools } from "./outgoingStatus"
export { prepareStatementAllocationInput } from "../statementAllocations" export { prepareStatementAllocationInput } from "../statementAllocations"
@@ -424,7 +423,6 @@ 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",

View File

@@ -1,166 +0,0 @@
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

@@ -1,148 +0,0 @@
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. 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,10 +1,9 @@
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, plants, projects, tasks } from "../../../db/schema" import { customers, events, 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) => {
@@ -18,7 +17,6 @@ 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",
@@ -240,6 +238,51 @@ 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

@@ -1,130 +0,0 @@
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

@@ -57,13 +57,10 @@ 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

@@ -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 = "", limit = 100, offset = 0) { export async function assignmentOptions(db: Reader, tenantId: number, kind: string, search = "") {
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(limit).offset(offset) .orderBy(asc(target.name), asc(target.id)).limit(100)
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,34 +264,32 @@ 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 storeDocument = async (tenantId: number, filename: string, body: Buffer, const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => {
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 authorize(tx) await assertWritable(tx, identity)
// 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(${`${tenantId}:${filename}`}, 0))`) await tx.execute(sql`SELECT pg_advisory_xact_lock(hashtextextended(${`${identity.tenantId}:${filename}`}, 0))`)
const [existing] = await tx.select().from(scannerInboxDocuments).where(and( const [existing] = await tx.select().from(scannerInboxDocuments).where(and(
eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.remoteFilename, filename), eq(scannerInboxDocuments.tenantId, identity.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 { id: existing.fileId, created: false, checksum } return { created: false, checksum }
} }
const id = randomUUID() const id = randomUUID()
const key = `${tenantId}/filesbyid/${id}/${sanitizeFilename(filename)}` const key = `${identity.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: tenantId, name: filename, path: key, mimeType: contentType, size: body.length, await tx.insert(files).values({ id, tenant: identity.tenantId, name: filename, path: key, mimeType: contentType, size: body.length })
...(userId ? { createdBy: userId } : {}) }) await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId: identity.tenantId, remoteFilename: filename, checksum })
await tx.insert(scannerInboxDocuments).values({ fileId: id, tenantId, remoteFilename: filename, checksum }) return { created: true, checksum }
return { id, created: true, checksum }
}) })
} catch (error) { } catch (error) {
if (uploadedKey) { if (uploadedKey) {
@@ -301,17 +299,8 @@ 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, upload } getAssignments, assignmentOptions: (tenantId: number, kind: string, search: string) => assignmentOptions(db, tenantId, kind, search), archiveDocument, fileDocument, probe, accept }
} }
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, isNull } from "drizzle-orm"; import { and, eq } 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,8 +44,7 @@ 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)
@@ -71,7 +70,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(and(eq(authUsers.id, userId), isNull(authUsers.archived_at))) .where(eq(authUsers.id, userId))
.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, isNull } from "drizzle-orm" import { eq, and, inArray } from "drizzle-orm"
export default fp(async (server: FastifyInstance) => { export default fp(async (server: FastifyInstance) => {
const hashApiKey = (apiKey: string) => const hashApiKey = (apiKey: string) =>
@@ -56,8 +56,7 @@ 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)
@@ -142,16 +141,22 @@ export default fp(async (server: FastifyInstance) => {
return reply.code(401).send({ error: "Invalid token" }) return reply.code(401).send({ error: "Invalid token" })
} }
// Check current account state even when a JWT carries cached admin claims. // Payload an Request hängen
const [currentUser] = await server.db req.user = payload
.select({ is_admin: authUsers.is_admin })
.from(authUsers) if (typeof payload.is_admin === "boolean") {
.where(and(eq(authUsers.id, payload.user_id), isNull(authUsers.archived_at))) req.user.is_admin = payload.is_admin
.limit(1) } else {
if (!currentUser) { const [currentUser] = await server.db
return reply.code(401).send({ error: "Invalid or expired token" }) .select({
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,12 +33,8 @@ 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 }
}); });
@@ -605,7 +601,6 @@ 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({
@@ -625,9 +620,8 @@ 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).where(includeArchived ? undefined : isNull(authUsers.archived_at)), .from(authUsers),
server.db server.db
.select({ .select({
id: authProfiles.id, id: authProfiles.id,
@@ -638,7 +632,6 @@ 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
@@ -687,17 +680,16 @@ 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 && activeUserIds.has(membership.user_id)).length, user_count: uniqueMembershipRows.filter((membership) => membership.tenant_id === tenant.id).length,
})); }));
return { return {
users, users,
tenants: tenantsWithCounts, tenants: tenantsWithCounts,
roles: roleRows, roles: roleRows,
unassignedProfiles: profileRows.filter((profile) => !profile.user_id && !profile.archived_at && profile.active), unassignedProfiles: profileRows.filter((profile) => !profile.user_id),
memberships: uniqueMembershipRows, memberships: uniqueMembershipRows,
roleAssignments: roleAssignmentRows, roleAssignments: roleAssignmentRows,
}; };
@@ -816,7 +808,7 @@ export default async function adminRoutes(server: FastifyInstance) {
email: authProfiles.email, email: authProfiles.email,
}) })
.from(authProfiles) .from(authProfiles)
.where(and(eq(authProfiles.id, profileId), isNull(authProfiles.archived_at))) .where(eq(authProfiles.id, profileId))
.limit(1); .limit(1);
if (!profile) { if (!profile) {
@@ -939,17 +931,6 @@ 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);
@@ -958,16 +939,11 @@ 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;
@@ -1110,6 +1086,8 @@ 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`,
@@ -1119,8 +1097,7 @@ 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>
<p><a href="${portalUrl}">Zum Kundenportal anmelden</a></p> ${portalUrl ? `<p><strong>Login:</strong> <a href="${portalUrl}">${portalUrl}</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>
` `
); );
@@ -1199,12 +1176,6 @@ 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;
@@ -1254,12 +1225,6 @@ 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();
@@ -1660,12 +1625,6 @@ 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 }[];
@@ -1742,9 +1701,7 @@ 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)
) )
) )
: []; : [];
@@ -1867,10 +1824,6 @@ 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

@@ -1,51 +0,0 @@
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,8 +6,7 @@ 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, isNull } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { resolveLoginTenant } from "../../utils/loginTenant";
import { import {
createAccessToken, createAccessToken,
issueRefreshToken, issueRefreshToken,
@@ -112,7 +111,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(and(eq(authUsers.email, body.email.toLowerCase()), isNull(authUsers.archived_at))) .where(eq(authUsers.email, body.email.toLowerCase()))
.limit(1); .limit(1);
if (!found) { if (!found) {
@@ -128,7 +127,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 = await resolveLoginTenant(server, user.id, req.tenant?.id ? Number(req.tenant.id) : null); const tenantId = 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);
@@ -216,7 +215,7 @@ export default async function authRoutes(server: FastifyInstance) {
mustChangePassword: authUsers.must_change_password, mustChangePassword: authUsers.must_change_password,
}) })
.from(authUsers) .from(authUsers)
.where(and(eq(authUsers.email, email.toLowerCase()), isNull(authUsers.archived_at))) .where(eq(authUsers.email, email.toLowerCase()))
.limit(1); .limit(1);
if (!user) { if (!user) {

View File

@@ -58,7 +58,6 @@ 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, isNull } from "drizzle-orm" import { eq, and } from "drizzle-orm"
import { import {
authUsers, authUsers,
@@ -83,8 +83,7 @@ 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)
) )
) )
@@ -100,7 +99,6 @@ 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)
@@ -118,10 +116,9 @@ 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(and(eq(authProfiles.id, profile.id), isNull(authProfiles.archived_at))) .where(eq(authProfiles.id, profile.id))
.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

@@ -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, isNull, ne, sql } from "drizzle-orm" import { and, asc, desc, eq, gt, inArray, 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(and(eq(authTenantUsers.tenant_id, tenantId), isNull(authUsers.archived_at))) .where(eq(authTenantUsers.tenant_id, tenantId))
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,8 +122,7 @@ 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)
.innerJoin(authUsers, eq(authUsers.id, authTenantUsers.user_id)) .where(eq(authTenantUsers.tenant_id, tenantId))
.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()
@@ -373,7 +372,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), eq(authProfiles.active, true))) .where(and(eq(authProfiles.tenant_id, tenantId), inArray(authProfiles.id, profileIds)))
: [] : []
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])

View File

@@ -44,8 +44,7 @@ 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

@@ -7,7 +7,7 @@ import {
tenants tenants
} from "../../../db/schema" } from "../../../db/schema"
import {and, eq, inArray, isNull} from "drizzle-orm" import {and, eq, inArray} 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(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at))) .where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden // 3) auth_profiles pro Tenant laden
const profileRows = await server.db const profileRows = await server.db
@@ -61,9 +61,7 @@ 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)
@@ -100,7 +98,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(and(eq(authProfiles.tenant_id, tenantId), eq(authProfiles.active, true), isNull(authProfiles.archived_at))) .where(eq(authProfiles.tenant_id, tenantId))
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,11 +6,6 @@ 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"
const SUPPORTED_PROTOCOL_VERSIONS = [ const SUPPORTED_PROTOCOL_VERSIONS = [
"2025-11-25", "2025-11-25",
@@ -200,28 +195,7 @@ export default async function mcpRoutes(server: FastifyInstance) {
} }
}) })
server.get<{ Params: { id: string } }>("/mcp/files/:id/content", { server.post("/mcp", async (req, reply) => {
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." })
}
})
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 = []
@@ -252,7 +226,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-, 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.", instructions: "FEDEO MCP-Server für mandantenbezogene Buchhaltungs- und Organisationswerkzeuge. Alle Tools prüfen Rollenberechtigungen und arbeiten im aktiven Mandanten.",
})) }))
continue continue
} }

View File

@@ -1,5 +1,5 @@
import { FastifyInstance } from "fastify"; import { FastifyInstance } from "fastify";
import { eq, and, isNull } from "drizzle-orm"; import { eq, and } 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", "archived_at", "calendar_subscription_token", "branch", "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,8 +135,7 @@ 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,14 +9,12 @@ 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,
@@ -140,10 +138,7 @@ 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, list = false) { function applyResourceWhereFilters(resource: string, table: any, whereCond: any) {
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"))
} }
@@ -575,7 +570,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, true) whereCond = applyResourceWhereFilters(resource, table, whereCond)
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()
@@ -693,7 +688,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, true) whereCond = applyResourceWhereFilters(resource, table, whereCond)
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 || [])];
@@ -794,7 +789,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, true) distinctWhereCond = applyResourceWhereFilters(resource, table, distinctWhereCond)
distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId) distinctWhereCond = applyPortalScope(resource, table, distinctWhereCond, portalCustomerId)
if (search) { if (search) {
@@ -883,7 +878,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(getTenantColumn(resource, table), tenantId)) let whereCond: any = and(eq(table.id, id), eq(table.tenant, tenantId))
whereCond = applyResourceWhereFilters(resource, table, whereCond) whereCond = applyResourceWhereFilters(resource, table, whereCond)
whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId) whereCond = applyPortalScope(resource, table, whereCond, portalCustomerId)
@@ -948,15 +943,6 @@ export default async function resourceRoutes(server: FastifyInstance) {
const table = config.table; const table = config.table;
let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false }; let createData: Record<string, any> = { ...body, tenant: req.user.tenant_id, archived: false };
if (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 })
@@ -1107,7 +1093,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(getTenantColumn(resource, table), tenantId)), portalCustomerId)) .where(applyPortalScope(resource, table, and(eq(table.id, id), eq(table.tenant, tenantId)), portalCustomerId))
.limit(1) .limit(1)
if (!oldRecord) { if (!oldRecord) {
@@ -1119,13 +1105,7 @@ 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
@@ -1214,8 +1194,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
} }
}) })
let updateWhereCond: any = and(eq(table.id, id), eq(getTenantColumn(resource, table), tenantId)) let updateWhereCond: any = and(eq(table.id, id), eq(table.tenant, 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()
@@ -1224,7 +1203,7 @@ export default async function resourceRoutes(server: FastifyInstance) {
.update(table) .update(table)
.set({ defaultMandate: false }) .set({ defaultMandate: false })
.where(and( .where(and(
eq(getTenantColumn(resource, table), tenantId), eq(table.tenant, 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, isNull} from "drizzle-orm" import {and, desc, eq, inArray} 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(and(inArray(authUsers.id, userIds), isNull(authUsers.archived_at))) .where(inArray(authUsers.id, userIds))
// 3) auth_profiles pro Tenant laden // 3) auth_profiles pro Tenant laden
const profileRows = await server.db const profileRows = await server.db
@@ -161,9 +161,7 @@ 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)
@@ -196,14 +194,10 @@ 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(and( .where(eq(authProfiles.tenant_id, tenantId))
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)
@@ -277,16 +271,6 @@ 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(and(eq(authUsers.id, session.userId), isNull(authUsers.archived_at))) .where(eq(authUsers.id, session.userId))
.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, isNull, sql } from "drizzle-orm" import { and, asc, eq, 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,8 +105,7 @@ 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,21 +2,17 @@ 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, maxBytes = Infinity): Promise<Buffer> => { export const streamToBuffer = async (stream: any): Promise<Buffer> => {
const chunks: Buffer[] = [] const chunks: Buffer[] = []
let size = 0
for await (const chunk of stream) { for await (const chunk of stream) {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) chunks.push(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, maxBytes = Infinity): Promise<Buffer> => { export const loadFileBuffer = async (path: string): 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,
@@ -26,5 +22,5 @@ export const loadFileBuffer = async (path: string, maxBytes = Infinity): Promise
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, maxBytes) return streamToBuffer(response.Body)
} }

View File

@@ -1,33 +0,0 @@
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,8 +488,6 @@ 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

@@ -1,29 +0,0 @@
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 { and, eq, isNull } from "drizzle-orm"; import { eq } 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(and(eq(authUsers.id, req.user.user_id), isNull(authUsers.archived_at))).limit(1); .from(authUsers).where(eq(authUsers.id, req.user.user_id)).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

@@ -1,126 +0,0 @@
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" && column.hasDefault) suffix += " DEFAULT now()" if (column.columnType === "PgTimestamp") suffix += " DEFAULT now()"
if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}` if (column.getSQLType() === "boolean") suffix += ` DEFAULT ${column.default === true ? "true" : "false"}`
return `"${column.name}" ${column.getSQLType()}${suffix}` return `"${column.name}" ${column.getSQLType()}${suffix}`
}) })

View File

@@ -1,219 +0,0 @@
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

@@ -1,213 +0,0 @@
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

@@ -10,9 +10,7 @@ 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 }
}) })
// 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)) {
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"]] }
@@ -44,7 +42,7 @@ for (const tool of mcpTools.filter((tool) => (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, { ...((tool.inputSchema.properties as any).id ? { id: 1 } : {}), limit: 100, offset }) const result: any = await tool.handler(context, { id: 1, 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

@@ -1,88 +0,0 @@
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,8 +18,6 @@ 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")}` })
@@ -613,64 +611,4 @@ 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

@@ -1,233 +0,0 @@
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

@@ -1,6 +1,6 @@
# Persönliche Benachrichtigungen # Persönliche Benachrichtigungen
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. 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.
## Ereignisse ## Ereignisse

View File

@@ -1,116 +1,5 @@
# 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,57 +0,0 @@
<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,8 +385,6 @@ 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",
@@ -477,21 +475,25 @@ 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 #leading> <template #left>
<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"
@@ -515,7 +517,7 @@ const updateItem = async () => {
Erstellen Erstellen
</UButton> </UButton>
<UButton <UButton
@click="router.push(getPostSaveRoute() || (item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`))" @click="router.push(item.id ? `/standardEntity/${type}/show/${item.id}` : `/standardEntity/${type}`)"
color="red" color="red"
class="ml-1" class="ml-1"
> >
@@ -525,9 +527,14 @@ 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"
@@ -579,14 +586,7 @@ const updateItem = async () => {
:item="item" :item="item"
/> />
</template> </template>
<ColorInput <InputGroup class="w-full" v-if="datapoint.key.includes('.')">
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,14 +833,7 @@ const updateItem = async () => {
:item="item" :item="item"
/> />
</template> </template>
<ColorInput <InputGroup class="w-full" v-if="datapoint.key.includes('.')">
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,16 +124,6 @@ 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([])
@@ -179,13 +169,6 @@ 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,12 +38,6 @@ 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",
@@ -324,11 +318,6 @@ 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 === 'advanceInvoices' ? null : props.type) const documentTypeToUse = ref(props.type)
const documentTypeItems = computed(() => { const documentTypeItems = computed(() => {
return Object.keys(dataStore.documentTypesForCreation).filter(key => key !== 'advanceInvoices').map((key) => ({ return Object.keys(dataStore.documentTypesForCreation).map((key) => ({
...dataStore.documentTypesForCreation[key], ...dataStore.documentTypesForCreation[key],
key key
})) }))
@@ -83,8 +83,6 @@ 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()
} }
@@ -149,7 +147,7 @@ const startImport = () => {
<UButton color="neutral" variant="ghost" @click="modal.close()"> <UButton color="neutral" variant="ghost" @click="modal.close()">
Abbrechen Abbrechen
</UButton> </UButton>
<UButton :disabled="!selectedDocumentType" @click="startImport"> <UButton @click="startImport">
Kopieren Kopieren
</UButton> </UButton>
</div> </div>

View File

@@ -10,11 +10,10 @@ const props = defineProps({
}) })
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {}) const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
.filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key)) .filter(([key]) => key !== 'serialInvoices')
.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 template.documentType !== 'advanceInvoices' return !selectedType.value || template.documentType === selectedType.value
&& (!selectedType.value || template.documentType === selectedType.value)
})) }))
const loadTemplates = async () => { const loadTemplates = async () => {
@@ -26,8 +25,6 @@ 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,12 +9,10 @@ const props = defineProps({
}) })
const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {}) const documentTypes = computed(() => Object.entries(dataStore.documentTypesForCreation || {})
.filter(([key]) => !['serialInvoices', 'advanceInvoices'].includes(key)) .filter(([key]) => key !== 'serialInvoices')
.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,13 +23,11 @@ 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
@@ -163,8 +161,8 @@ export type CreateInstanceAgentResult = {
export const useAdmin = () => { export const useAdmin = () => {
const { $api } = useNuxtApp() const { $api } = useNuxtApp()
const getOverview = async (includeArchived = false): Promise<AdminOverview> => { const getOverview = async (): Promise<AdminOverview> => {
const response = await $api("/api/admin/overview", { query: { includeArchived } }) as any const response = await $api("/api/admin/overview") as any
return { return {
users: response?.users || [], users: response?.users || [],
@@ -195,10 +193,6 @@ 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",
@@ -300,7 +294,6 @@ export const useAdmin = () => {
createUserForProfile, createUserForProfile,
updateUser, updateUser,
resetUserPassword, resetUserPassword,
archiveUser,
updateUserAccess, updateUserAccess,
createTenant, createTenant,
invitePortalUser, invitePortalUser,

View File

@@ -45,7 +45,10 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
// 3. Wenn eingeloggt // 3. Wenn eingeloggt
if (to.path === "/login") { if (to.path === "/login") {
return navigateTo(auth.getLandingPath()) if (auth.user.must_change_password) {
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,10 +11,6 @@ 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("")
@@ -106,7 +102,7 @@ const fetchUser = async () => {
loading.value = true loading.value = true
try { try {
const overview = await admin.getOverview(true) const overview = await admin.getOverview()
roles.value = overview.roles roles.value = overview.roles
tenants.value = overview.tenants tenants.value = overview.tenants
unassignedProfiles.value = overview.unassignedProfiles unassignedProfiles.value = overview.unassignedProfiles
@@ -140,7 +136,7 @@ const fetchUser = async () => {
} }
const saveUser = async () => { const saveUser = async () => {
if (!userForm.value || userForm.value.archived_at || saving.value || archiving.value) return if (!userForm.value || saving.value) return
saving.value = true saving.value = true
normalizeUserAssignments() normalizeUserAssignments()
@@ -176,24 +172,8 @@ 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 || userForm.value.archived_at || resettingPassword.value || archiving.value) return if (!userForm.value || resettingPassword.value) return
const password = resetPasswordInput.value.trim() const password = resetPasswordInput.value.trim()
if (password && password.length < 8) { if (password && password.length < 8) {
@@ -268,40 +248,21 @@ 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" :disabled="loading || !userForm || !!userForm.archived_at || archiving || resettingPassword" @click="saveUser"> <UButton color="primary" :loading="saving" @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>
@@ -312,7 +273,7 @@ onMounted(async () => {
<USeparator label="Benutzer" /> <USeparator label="Benutzer" />
<UForm :disabled="!!userForm.archived_at || archiving" :state="userForm" class="grid grid-cols-1 md:grid-cols-2 gap-6 mt-4"> <UForm :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>
@@ -380,7 +341,6 @@ 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"
@@ -392,7 +352,6 @@ 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 },
@@ -431,7 +390,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.archived_at ? " · Archiviert" : "" }} {{ profile.full_name || `${profile.first_name} ${profile.last_name}` }} · Tenant {{ profile.tenant_id }}
</UBadge> </UBadge>
</div> </div>
</UCard> </UCard>
@@ -439,27 +398,6 @@ 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,7 +12,6 @@ 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: "",
@@ -28,15 +27,13 @@ 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.filter((user) => showArchived.value || !user.archived_at).map((user) => ({ const rows = users.value.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",
})) }))
@@ -53,7 +50,7 @@ const fetchUsers = async () => {
loading.value = true loading.value = true
try { try {
const overview = await admin.getOverview(true) const overview = await admin.getOverview()
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)
@@ -122,7 +119,6 @@ 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

@@ -104,36 +104,10 @@ const bankaccounts = ref([])
const texttemplates = ref([]) const texttemplates = ref([])
const units = ref([]) const units = ref([])
const tenantUsers = ref([]) const tenantUsers = ref([])
const originalDocumentType = ref(null) const documentTypeItems = computed(() => Object.keys(dataStore.documentTypesForCreation).map((key) => ({
const canUseAdvanceInvoice = computed(() => originalDocumentType.value === 'advanceInvoices' || ( label: dataStore.documentTypesForCreation[key].labelSingle,
route.query.type === 'advanceInvoices' type: key
&& 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' },
@@ -417,7 +391,6 @@ 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()
@@ -726,10 +699,6 @@ const setupPage = async () => {
itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows) itemInfo.value.rows = normalizeCreatedDocumentRows(itemInfo.value.rows)
if (!validateAdvanceInvoiceCreation()) {
await router.replace('/createDocument')
return
}
loaded.value = true loaded.value = true
} }
@@ -1848,7 +1817,6 @@ 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()

View File

@@ -1,6 +1,5 @@
<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"
@@ -8,7 +7,6 @@ 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[]>([])
@@ -339,7 +337,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<div :style="portalTheme" class="min-h-screen bg-gradient-to-b from-neutral-50 via-white to-neutral-100"> <div 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">
@@ -394,7 +392,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" :ui="{ trigger: 'data-[state=active]:text-[var(--portal-accent-contrast)]' }" class="portal-tabs"> <UTabs v-model="activeTab" :items="portalTabs" 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">
@@ -472,7 +470,7 @@ onMounted(async () => {
</div> </div>
<div class="mt-6 flex justify-end"> <div class="mt-6 flex justify-end">
<UButton icon="i-heroicons-check" class="text-[var(--portal-accent-contrast)]" :loading="saving" @click="saveCustomerData"> <UButton icon="i-heroicons-check" :loading="saving" @click="saveCustomerData">
Kundendaten speichern Kundendaten speichern
</UButton> </UButton>
</div> </div>
@@ -772,7 +770,7 @@ onMounted(async () => {
</div> </div>
</UContainer> </UContainer>
<UModal v-model:open="contractChangeModalOpen" :content="{ style: portalTheme }"> <UModal v-model:open="contractChangeModalOpen">
<template #content> <template #content>
<div class="space-y-5 p-6"> <div class="space-y-5 p-6">
<div> <div>
@@ -789,7 +787,6 @@ 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"
@@ -816,7 +813,6 @@ 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"
@@ -828,7 +824,7 @@ onMounted(async () => {
</template> </template>
</UModal> </UModal>
<UModal v-model:open="cancellationModalOpen" :content="{ style: portalTheme }"> <UModal v-model:open="cancellationModalOpen">
<template #content> <template #content>
<div class="space-y-5 p-6"> <div class="space-y-5 p-6">
<div> <div>

View File

@@ -1,206 +0,0 @@
<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

@@ -7,6 +7,7 @@ definePageMeta({
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
const router = useRouter()
const state = reactive({ const state = reactive({
email: '', email: '',
@@ -21,6 +22,7 @@ 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.fetchMe() await auth.logout()
return navigateTo(auth.getLandingPath(), { replace: true }) return navigateTo("/login")
} 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

@@ -1,9 +0,0 @@
<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,7 +3,6 @@ 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()
@@ -261,16 +260,6 @@ 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 = [
@@ -299,18 +288,6 @@ 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"
@@ -637,8 +614,6 @@ onMounted(() => {
label: 'Dokubox' label: 'Dokubox'
},{ },{
label: 'Rechnung & Kontakt' label: 'Rechnung & Kontakt'
},{
label: 'Kundenportal'
},{ },{
label: 'Plantafel' label: 'Plantafel'
},{ },{
@@ -780,43 +755,6 @@ 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,11 +31,6 @@ 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: "/" })
}, },
@@ -270,14 +265,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) {
return navigateTo(this.getLandingPath()) this.loading = false
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", {
@@ -294,15 +289,21 @@ export const useAuthStore = defineStore("auth", {
await this.fetchMe(token) await this.fetchMe(token)
if (!this.user) throw new Error("Die Sitzung konnte nicht geladen werden.") // 3. WICHTIG: Jetzt explizit weiterleiten!
this.loading = false // Prüfen, ob der User geladen wurde
return await navigateTo(this.getLandingPath(), { replace: true }) if (this.user) {
// 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: "color", inputType: "text",
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', 'projectNumber'], selectSearchAttributes: ['name'],
},{ },{
key: "vehicles", key: "vehicles",
label: "Fahrzeuge", label: "Fahrzeuge",

View File

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

View File

@@ -1,131 +0,0 @@
const { test } = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const vm = require('node:vm')
const ts = require('typescript')
const { parse, compileScript } = require('@vue/compiler-sfc')
const documentTypesForCreation = Object.fromEntries([
'invoices', 'advanceInvoices', 'serialInvoices', 'quotes', 'costEstimates',
'deliveryNotes', 'packingSlips', 'confirmationOrders', 'cancellationInvoices',
].map(key => [key, { labelSingle: key }]))
function fixture(filename, names, globals = {}) {
const { descriptor } = parse(fs.readFileSync(`${__dirname}/../${filename}`, 'utf8'))
compileScript(descriptor, { id: filename, inlineTemplate: true })
const script = ts.createSourceFile(filename, descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
const source = script.statements.filter(statement => ts.isVariableStatement(statement)
&& statement.declarationList.declarations.some(declaration => names.includes(declaration.name.getText(script))))
.map(statement => statement.getText(script)).join('\n')
const navigations = []
const notices = []
const context = vm.createContext({
ref: value => ({ value }), computed: getter => ({ get value() { return getter() } }),
dataStore: { documentTypesForCreation }, URLSearchParams,
router: { push: url => navigations.push(url), replace: url => navigations.push(url) },
modal: { close() {} }, toast: { add: notice => notices.push(notice) },
...globals,
})
vm.runInContext(source, context)
return { context, navigations, notices, run: code => vm.runInContext(code, context) }
}
test('copying any document excludes advance invoices and requires an allowed target', () => {
for (const type of Object.keys(documentTypesForCreation)) {
const { run, navigations } = fixture('components/copyCreatedDocumentModal.vue', [
'documentTypeToUse', 'documentTypeItems', 'selectedDocumentType', 'optionsToImport', 'startImport',
], { props: { id: '12', type } })
assert.equal(run("documentTypeItems.value.some(item => item.key === 'advanceInvoices')"), false)
assert.equal(run('documentTypeToUse.value'), type === 'advanceInvoices' ? null : type)
run("documentTypeToUse.value = 'advanceInvoices'; startImport()")
assert.equal(navigations.length, 0)
run("documentTypeToUse.value = 'invoices'; startImport()")
const query = new URL(navigations[0], 'http://localhost').searchParams
assert.equal(query.get('type'), 'invoices')
assert.equal(query.get('createddocument'), '12')
}
})
test('+ Dokument excludes advance invoices even when opened inside a project', () => {
for (const queryStringData of ['', 'project=7&customer=8']) {
const { run, navigations } = fixture('components/createDocumentModal.vue', ['documentTypes', 'createDocument'], {
props: { queryStringData },
})
assert.equal(run("documentTypes.value.some(item => item.key === 'advanceInvoices')"), false)
run("createDocument('advanceInvoices')")
assert.equal(navigations.length, 0)
run("createDocument('quotes')")
assert.equal(new URL(navigations[0], 'http://localhost').searchParams.get('type'), 'quotes')
}
})
test('advance invoice templates cannot create outgoing documents', () => {
const { run, navigations } = fixture('components/createDocumentFromTemplateModal.vue', [
'templates', 'selectedType', 'documentTypes', 'visibleTemplates', 'selectTemplate',
], { props: { queryStringData: 'project=7' } })
run("templates.value = [{ id: 1, documentType: 'advanceInvoices' }, { id: 2, documentType: 'quotes' }]")
assert.equal(run("documentTypes.value.some(item => item.key === 'advanceInvoices')"), false)
assert.equal(run('visibleTemplates.value.length'), 1)
assert.equal(run('visibleTemplates.value[0].id'), 2)
run('selectTemplate(templates.value[0])')
assert.equal(navigations.length, 0)
run('selectTemplate(templates.value[1])')
assert.equal(new URL(navigations[0], 'http://localhost').searchParams.get('templateId'), '2')
})
function editorFixture(query = {}, id) {
return fixture('pages/createDocument/edit/[[id]].vue', [
'originalDocumentType', 'canUseAdvanceInvoice', 'documentTypeItems', 'normalizeEntityId',
'validateAdvanceInvoiceCreation', 'saveDocument',
], {
route: { params: { id }, query }, isTemplateMode: { value: false },
itemInfo: { value: { type: 'advanceInvoices', project: 7 } },
})
}
test('only the project action opens a new advance invoice with its project and customer', () => {
const { run, navigations, context } = fixture('components/EntityShowSubCreatedDocuments.vue', ['createAdvanceInvoice'], {
props: { topLevelType: 'projects', item: { id: 7 }, queryStringData: 'customer=8&project=99' },
})
run('createAdvanceInvoice()')
const query = Object.fromEntries(new URL(navigations[0], 'http://localhost').searchParams)
assert.equal(query.project, '7')
assert.equal(query.customer, '8')
const editor = editorFixture(query)
assert.equal(editor.run('validateAdvanceInvoiceCreation()'), true)
assert.equal(editor.run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), true)
context.props.topLevelType = 'customers'
run('createAdvanceInvoice()')
assert.equal(navigations.length, 1)
})
test('editor rejects alternate creation routes and saving a switched document type', async () => {
const projectQuery = { type: 'advanceInvoices', loadMode: 'advanceInvoice', project: '7' }
for (const query of [
{}, { type: 'advanceInvoices' }, { type: 'advanceInvoices', project: '7' },
{ ...projectQuery, project: '' }, { ...projectQuery, project: 'invalid' },
{ ...projectQuery, createddocument: '12' }, { ...projectQuery, optionsToImport: '{}' },
{ ...projectQuery, linkedDocuments: '[12]' }, { ...projectQuery, templateId: '5' },
]) {
const { run, notices, navigations } = editorFixture(query)
assert.equal(run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), false)
assert.equal(run('validateAdvanceInvoiceCreation()'), false)
await run("saveDocument('Entwurf')")
assert.equal(notices.length, 2)
assert.equal(navigations.length, 0)
}
})
test('new advance invoices require a project; existing advance invoices remain editable', () => {
const fresh = editorFixture({ type: 'advanceInvoices', loadMode: 'advanceInvoice', project: '7' })
fresh.run('itemInfo.value.project = null')
assert.equal(fresh.run('validateAdvanceInvoiceCreation()'), false)
const existing = editorFixture({}, '12')
existing.run("originalDocumentType.value = 'advanceInvoices'; itemInfo.value.project = null")
assert.equal(existing.run('validateAdvanceInvoiceCreation()'), true)
assert.equal(existing.run("documentTypeItems.value.some(item => item.type === 'advanceInvoices')"), true)
existing.run("originalDocumentType.value = 'invoices'")
assert.equal(existing.run('validateAdvanceInvoiceCreation()'), false)
existing.run("itemInfo.value.type = 'invoices'")
assert.equal(existing.run('validateAdvanceInvoiceCreation()'), true)
})

View File

@@ -80,7 +80,7 @@ test('an account without assigned tenants receives an explanation without loadin
}) })
function authFixture() { function authFixture() {
const logs = [], calls = [], sessions = [], navigations = [] const logs = [], calls = [], sessions = []
const token = 'test-access-token' const token = 'test-access-token'
const refreshToken = 'test-refresh-token' const refreshToken = 'test-refresh-token'
let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null } let me = { user: { email: 'test@example.com' }, activeTenant: null, tenants: [], permissions: [], profile: null }
@@ -89,15 +89,10 @@ function authFixture() {
defineStore: (_, definition) => definition, defineStore: (_, definition) => definition,
console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) }, console: { log: (...args) => logs.push(args), error: (...args) => logs.push(args) },
useTempStore: () => ({ setStoredTempConfig() {} }), useTempStore: () => ({ setStoredTempConfig() {} }),
navigateTo: path => { navigations.push(path) }, navigateTo() {},
useNuxtApp: () => ({ $api: async (url, options) => { useNuxtApp: () => ({ $api: async (url, options) => {
calls.push({ url, options }) calls.push({ url, options })
if (url === '/api/me') return me if (url === '/api/me') return me
if (url === '/api/tenant') return { id: me.activeTenant }
if (url === '/api/auth/password/change') {
me = { ...me, user: { ...me.user, must_change_password: false } }
return { success: true }
}
if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken } if (url === '/auth/login' || url === '/api/tenant/switch') return { token, refreshToken }
throw Error('Unexpected API request: ' + url) throw Error('Unexpected API request: ' + url)
} }), } }),
@@ -111,7 +106,7 @@ function authFixture() {
store.getStoredRefreshToken = () => refreshToken store.getStoredRefreshToken = () => refreshToken
store.scheduleSessionTimers = () => {} store.scheduleSessionTimers = () => {}
store.setSession = (...args) => sessions.push(args) store.setSession = (...args) => sessions.push(args)
return { store, context, logs, calls, sessions, navigations, token, refreshToken, setMe: value => { me = value } } return { store, logs, calls, sessions, token, refreshToken, setMe: value => { me = value } }
} }
test('refreshing a session without an active tenant clears the previous tenant state', async () => { test('refreshing a session without an active tenant clears the previous tenant state', async () => {
@@ -135,77 +130,3 @@ test('login and tenant switching store credentials without writing them to the c
assert.ok(!output.includes(f.token)) assert.ok(!output.includes(f.token))
assert.ok(!output.includes(f.refreshToken)) assert.ok(!output.includes(f.refreshToken))
}) })
function portalSession(mustChangePassword = false) {
return {
user: { email: 'customer@example.com', must_change_password: mustChangePassword },
activeTenant: 7, tenants: [{ id: 7, name: 'Test tenant' }], permissions: [],
profile: { customer_for_portal: 123 },
}
}
function runPage(f, name, action, data) {
const { descriptor } = parse(fs.readFileSync(__dirname + `/../pages/${name}.vue`, 'utf8'))
Object.assign(f.context, {
useAuthStore: () => f.store, useToast: () => ({ add() {} }), definePageMeta() {},
useRouter: () => ({ push: path => { f.navigations.push(path) } }),
reactive: vue.reactive, ref: vue.ref, formData: data,
})
const source = descriptor.scriptSetup.content.replace(/^import .*$/gm, '')
vm.runInContext(ts.transpile(source, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), f.context)
return vm.runInContext(`${action}({ data: formData })`, f.context)
}
test('the login form navigates portal customers once, directly to their portal', async () => {
const f = authFixture()
f.setMe(portalSession())
await runPage(f, 'login', 'doLogin', { email: 'customer@example.com', password: 'password', rememberMe: true })
assert.deepEqual(f.navigations, ['/customer-portal'])
assert.equal(f.store.loading, false)
assert.equal(f.store.profile.customer_for_portal, 123)
assert.equal(f.store.activeTenant, '7')
})
test('first login opens password change and continues into the portal after saving', async () => {
const f = authFixture()
f.setMe(portalSession(true))
await f.store.login('customer@example.com', 'initial-password')
assert.deepEqual(f.navigations, ['/password-change'])
await runPage(f, 'password-change', 'doChange', { oldPassword: 'initial-password', newPassword: 'new-password' })
assert.deepEqual(f.navigations, ['/password-change', '/customer-portal'])
assert.equal(f.store.user.must_change_password, false)
assert.equal(f.calls.some(call => call.url === '/auth/logout'), false)
})
test('internal users still land on the dashboard', async () => {
const f = authFixture()
await runPage(f, 'login', 'doLogin', { email: 'staff@example.com', password: 'password' })
assert.deepEqual(f.navigations, ['/'])
assert.equal(f.store.loading, false)
})
test('restoring a portal session keeps portal routing and password-change precedence', async () => {
const f = authFixture()
f.setMe(portalSession())
Object.assign(f.context, { defineNuxtRouteMiddleware: fn => fn, useAuthStore: () => f.store })
const middleware = fs.readFileSync(__dirname + '/../middleware/auth.global.ts', 'utf8')
vm.runInContext(ts.transpile(middleware, { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS }), f.context)
const route = f.context.exports.default
await route({ path: '/' })
assert.deepEqual(f.navigations, ['/customer-portal'])
await route({ path: '/customer-portal' })
assert.equal(f.navigations.length, 1)
await route({ path: '/login' })
assert.equal(f.navigations.at(-1), '/customer-portal')
f.store.user.must_change_password = true
await route({ path: '/customer-portal' })
assert.equal(f.navigations.at(-1), '/password-change')
})
test('selecting a portal tenant also opens its portal', async () => {
const f = authFixture()
f.setMe(portalSession())
await f.store.switchTenant('7')
assert.deepEqual(f.navigations, ['/customer-portal'])
assert.equal(f.store.loading, false)
})

View File

@@ -8,7 +8,7 @@ const { parse } = require('@vue/compiler-sfc')
const { descriptor } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8')) const { descriptor } = parse(fs.readFileSync(`${__dirname}/../pages/createDocument/edit/[[id]].vue`, 'utf8'))
const script = ts.createSourceFile('document-editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS) const script = ts.createSourceFile('document-editor.js', descriptor.scriptSetup.content, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS)
const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'originalDocumentType', 'canUseAdvanceInvoice', 'validateAdvanceInvoiceCreation', 'setupPage', 'setRowData']) const handlers = new Set(['normalizeEntityId', 'normalizeUnitId', 'normalizeCreatedDocumentRow', 'normalizeCreatedDocumentRows', 'normalizeTaxTypeValue', 'setupPage', 'setRowData'])
const source = script.statements const source = script.statements
.filter(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => handlers.has(declaration.name.getText(script)))) .filter(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => handlers.has(declaration.name.getText(script))))
.map(statement => statement.getText(script)).join('\n') .map(statement => statement.getText(script)).join('\n')
@@ -18,8 +18,6 @@ function fixture(unit) {
const product = { id: 20, name: 'Artikel', unit, selling_price: 10, tax_percentage: 19 } const product = { id: 20, name: 'Artikel', unit, selling_price: 10, tax_percentage: 19 }
const service = { id: 30, name: 'Leistung', unit, sellingPrice: 20, sellingPriceComposed: {}, taxPercentage: 19 } const service = { id: 30, name: 'Leistung', unit, sellingPrice: 20, sellingPriceComposed: {}, taxPercentage: 19 }
const context = vm.createContext({ const context = vm.createContext({
ref, computed: getter => ({ get value() { return getter() } }),
isTemplateMode: ref(false),
dayjs, uuidv4: () => 'row-id', console: { log() {} }, dayjs, uuidv4: () => 'row-id', console: { log() {} },
products: ref([product]), services: ref([service]), products: ref([product]), services: ref([service]),
productcategories: ref([]), servicecategories: ref([]), productcategories: ref([]), servicecategories: ref([]),

View File

@@ -1,166 +0,0 @@
// Run against a built frontend with PLAYWRIGHT_MODULE pointing to an installed Playwright package.
// Requests use isolated fixtures; no real account or application data is changed.
import assert from 'node:assert/strict'
import { readFile, writeFile } from 'node:fs/promises'
const { chromium } = await import(process.env.PLAYWRIGHT_MODULE || 'playwright')
const base = process.env.EVENTS_FRONTEND_URL || 'http://127.0.0.1:3157'
const browser = await chromium.launch({ headless: true })
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } })
page.setDefaultTimeout(10000)
const errors = []
const writes = []
const listRequests = []
const iconCollections = Object.fromEntries(await Promise.all(['heroicons', 'lucide'].map(async prefix => [
prefix, JSON.parse(await readFile(new URL(`../node_modules/@iconify/json/json/${prefix}.json`, import.meta.url), 'utf8'))
])))
let failList = false
let allowCreate = true
const project = { id: 42, name: 'Umbau Hafenstraße', projectNumber: 'P-2026-042' }
const profile = { id: '11111111-1111-4111-8111-111111111111', full_name: 'Anna Beispiel', pinned_on_navigation: [], temp_config: {}, role: { rights: [] } }
const tenant = { id: 1, name: 'Testbetrieb', extraModules: [], features: {}, locked: 'none' }
let events = Array.from({ length: 26 }, (_, index) => ({
id: index + 1, name: index ? `Termin ${index + 1}` : 'Baubesprechung Hafen',
startDate: `2026-10-${String(index + 1).padStart(2, '0')}T08:00:00.000Z`,
endDate: index ? null : '2026-10-01T09:30:00.000Z',
project: index ? null : project, state: index ? 'Final' : 'Entwurf',
color: '#2563eb', quick: false, archived: false, profiles: null,
repeatInterval: index ? 'Keine Wiederholung' : 'Wöchentlich'
}))
page.on('pageerror', error => errors.push(error.message))
await page.addInitScript(() => localStorage.setItem('token', 'fixture-only'))
await page.route('**/*', async route => {
const url = new URL(route.request().url())
if (url.hostname === 'api.iconify.design') {
const collection = iconCollections[url.pathname.slice(1).replace('.json', '')]
if (collection) return route.fulfill({ contentType: 'application/json', body: JSON.stringify(collection) })
}
if (url.origin !== new URL(base).origin) return route.abort()
if (url.pathname.startsWith('/api/_nuxt_icon/')) return route.continue()
if (!url.pathname.startsWith('/api/') && !url.pathname.startsWith('/auth/')) return route.continue()
let body = []
if (url.pathname === '/api/me') body = {
user: { id: profile.id, email: 'anna@example.test', is_admin: false }, profile, tenants: [tenant], activeTenant: 1,
permissions: ['events', 'projects', ...(allowCreate ? ['events-create'] : [])]
}
else if (url.pathname === '/api/tenant') body = tenant
else if (url.pathname === '/api/tenant/profiles') body = { data: [profile] }
else if (url.pathname === '/api/resource/projects') body = [project]
else if (url.pathname === '/api/resource/events/paginated') {
if (failList) return route.fulfill({ status: 503, contentType: 'application/json', body: '{"error":"Fixture unavailable"}' })
const search = (url.searchParams.get('search') || '').toLowerCase()
const state = url.searchParams.get('filter[state]')
const limit = Number(url.searchParams.get('limit'))
const currentPage = Number(url.searchParams.get('page'))
listRequests.push(Object.fromEntries(url.searchParams))
const matches = events.filter(event => !event.archived && (!state || state === event.state)
&& [event.name, event.project?.name, event.project?.projectNumber].some(value => String(value || '').toLowerCase().includes(search)))
matches.sort((a, b) => url.searchParams.get('sort')?.endsWith(':asc')
? a.startDate.localeCompare(b.startDate) : b.startDate.localeCompare(a.startDate))
body = { data: matches.slice((currentPage - 1) * limit, currentPage * limit), queryConfig: { total: matches.length } }
}
else if (url.pathname === '/api/resource/events' && route.request().method() === 'POST') {
const payload = route.request().postDataJSON()
writes.push(payload)
body = { id: 100, state: 'Final', repeatInterval: 'Keine Wiederholung', ...payload, archived: false }
events.push({ ...body, project: payload.project === project.id ? project : null })
}
else if (/^\/api\/resource\/events\/\d+(\/true)?$/.test(url.pathname)) {
const event = events.find(item => item.id === Number(url.pathname.split('/')[4]))
body = { ...event, project: event?.project?.id || null }
if (route.request().method() === 'PUT') {
Object.assign(event, route.request().postDataJSON())
writes.push(route.request().postDataJSON())
body = event
}
}
await route.fulfill({ contentType: 'application/json', body: JSON.stringify(body) })
})
const searchInput = () => page.getByRole('textbox', { name: 'Termine durchsuchen' })
const waitForList = () => page.getByRole('table').waitFor()
try {
await page.goto(`${base}/events`, { waitUntil: 'networkidle' })
await waitForList()
assert.equal(await page.getByRole('columnheader').count(), 5)
assert.equal(await page.locator('tbody tr').count(), 25)
await page.getByRole('button', { name: 'Next Page', exact: true }).click()
await page.getByRole('link', { name: 'Baubesprechung Hafen', exact: true }).first().waitFor()
assert.equal(listRequests.at(-1).page, '2')
await searchInput().fill('P-2026-042')
await page.getByText('P-2026-042 · Umbau Hafenstraße', { exact: true }).first().waitFor()
await waitForList()
assert.equal(listRequests.at(-1).page, '1')
assert.equal(await page.locator('tbody tr').count(), 1)
await page.screenshot({ path: '/tmp/fedeo-events-desktop.png', fullPage: true })
await page.getByRole('combobox', { name: 'Status filtern' }).click()
await page.getByRole('option', { name: 'Final', exact: true }).click()
await page.getByRole('heading', { name: 'Keine passenden Termine' }).waitFor()
await page.getByRole('button', { name: 'Filter zurücksetzen' }).click()
await waitForList()
await page.getByRole('link', { name: 'Neuer Termin', exact: true }).click()
await page.getByRole('heading', { name: 'Termin erstellen' }).waitFor()
await page.getByLabel('Name', { exact: true }).fill('Neu aus Terminübersicht')
for (let index = 0; index < 2; index++) {
await page.getByRole('button', { name: 'Datum auswählen', exact: true }).first().click()
await page.getByRole('button', { name: 'Heute', exact: true }).click()
await page.keyboard.press('Escape')
}
await page.getByLabel('Farbe auswählen', { exact: true }).fill('#f43f5e')
const projectInputId = await page.locator('label').filter({ hasText: /^Projekt$/ }).getAttribute('for')
await page.locator(`[id="${projectInputId}"]`).click()
await page.getByPlaceholder('Suche...', { exact: true }).fill('2026-042')
await page.getByRole('option', { name: project.name, exact: true }).click()
await page.getByRole('button', { name: 'Erstellen', exact: true }).click()
await page.waitForURL(`${base}/events`)
await searchInput().fill('Neu aus Terminübersicht')
await page.getByRole('link', { name: 'Neu aus Terminübersicht', exact: true }).first().waitFor()
assert.equal(writes[0].project, 42)
assert.equal(writes[0].color, '#f43f5e')
assert.ok(writes[0].startDate && writes[0].endDate)
await page.getByRole('link', { name: 'Neu aus Terminübersicht', exact: true }).first().click()
await page.getByRole('heading', { name: 'Termin bearbeiten' }).waitFor()
await page.getByLabel('Name', { exact: true }).fill('Bearbeiteter Termin')
await page.getByRole('button', { name: 'Speichern', exact: true }).click()
await page.waitForURL(`${base}/events`)
await waitForList()
await page.setViewportSize({ width: 390, height: 844 })
await searchInput().fill('P-2026-042')
await page.getByRole('heading', { name: 'Baubesprechung Hafen' }).waitFor()
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false)
await page.getByRole('link', { name: 'Neuer Termin', exact: true }).waitFor()
await page.screenshot({ path: '/tmp/fedeo-events-mobile.png', fullPage: true })
await page.setViewportSize({ width: 1440, height: 1000 })
await page.goto(`${base}/standardEntity/events`, { waitUntil: 'networkidle' })
await searchInput().waitFor()
failList = true
await page.getByRole('button', { name: 'Termine aktualisieren' }).click()
await page.getByText('Laden fehlgeschlagen', { exact: true }).waitFor()
failList = false
await page.getByRole('button', { name: 'Erneut versuchen' }).click()
await waitForList()
events = []
await page.getByRole('button', { name: 'Termine aktualisieren' }).click()
await page.getByRole('heading', { name: 'Noch keine Termine' }).waitFor()
assert.equal(await page.getByRole('link', { name: 'Neuer Termin', exact: true }).count(), 2)
allowCreate = false
await page.reload({ waitUntil: 'networkidle' })
await page.getByRole('heading', { name: 'Noch keine Termine' }).waitFor()
assert.equal(await page.getByRole('link', { name: 'Neuer Termin', exact: true }).count(), 0)
assert.deepEqual(errors, [], 'Browser runtime errors')
console.log('Events browser checks passed: pagination, project-number search, status filter, create and edit with return to list, mobile layout, legacy URL, errors, empty state and create permission.')
} catch (error) {
await writeFile('/tmp/fedeo-events-error.html', await page.content())
await page.screenshot({ path: '/tmp/fedeo-events-error.png', fullPage: true })
throw error
} finally {
await browser.close()
}

View File

@@ -1,32 +0,0 @@
export const DEFAULT_PORTAL_COLOR = "#69c350"
export function normalizePortalColor(value: unknown): string | null {
if (typeof value !== "string") return null
const color = value.trim().toLowerCase()
return /^#[0-9a-f]{6}$/.test(color) ? color : null
}
export function getPortalColor(config: any): string {
// Older tenant records contain the misspelled key from the original schema.
return normalizePortalColor(config?.primaryColor)
|| normalizePortalColor(config?.primayColor)
|| DEFAULT_PORTAL_COLOR
}
export function portalThemeStyle(value: unknown): Record<string, string> {
const color = normalizePortalColor(value) || DEFAULT_PORTAL_COLOR
const rgb = [1, 3, 5].map(offset => parseInt(color.slice(offset, offset + 2), 16) / 255)
const linear = rgb.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
const luminance = linear[0] * 0.2126 + linear[1] * 0.7152 + linear[2] * 0.0722
const style: Record<string, string> = {
"--ui-primary": color,
"--portal-accent-contrast": luminance > 0.179 ? "#000000" : "#ffffff",
}
const shades = { 50: 5, 100: 10, 200: 25, 300: 45, 400: 70, 500: 100, 600: 80, 700: 65, 800: 50, 900: 35, 950: 20 }
for (const [shade, percent] of Object.entries(shades)) {
style[`--ui-color-primary-${shade}`] = Number(shade) === 500
? color
: `color-mix(in srgb, ${color} ${percent}%, ${Number(shade) < 500 ? "white" : "black"})`
}
return style
}