Compare commits

...

4 Commits

Author SHA1 Message Date
9822d90bdd KI-AGENT: Verbinden-Button für Bankkonten repariert
All checks were successful
Build and Push Docker Images / build-backend (push) Successful in 1m2s
Build and Push Docker Images / build-website (push) Successful in 37s
Build and Push Docker Images / build-frontend (push) Successful in 1m58s
Build and Push Docker Images / build-central-services-api (push) Successful in 36s
Build and Push Docker Images / build-central-services-admin (push) Successful in 35s
Build and Push Docker Images / build-docs (push) Successful in 35s
2026-08-07 21:13:06 +02:00
519a90bdc1 KI-AGENT: Mandantenbezogenen IMAP-Dokumentenimport umsetzen 2026-08-07 21:07:05 +02:00
75d7bfab38 KI-AGENT: Notizen für Bankbuchungen ergänzt 2026-08-07 19:21:38 +02:00
9421c3221c Dashboard-Ladezeit durch geteilte Datenabfragen optimiert 2026-08-07 19:07:20 +02:00
20 changed files with 943 additions and 20 deletions

View File

@@ -0,0 +1 @@
ALTER TABLE "bankstatements" ADD COLUMN "notes" text;

View File

@@ -0,0 +1,69 @@
CREATE TABLE "document_import_sources" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"created_by" uuid,
"name" text NOT NULL,
"provider" text DEFAULT 'imap' NOT NULL,
"enabled" boolean DEFAULT true NOT NULL,
"mailbox_address_encrypted" jsonb,
"password_encrypted" jsonb,
"imap_host_encrypted" jsonb,
"imap_port" integer DEFAULT 993 NOT NULL,
"imap_secure" boolean DEFAULT true NOT NULL,
"mailbox_path" text DEFAULT 'INBOX' NOT NULL,
"target_folder_id" uuid,
"default_filetype_id" uuid,
"mark_as_seen" boolean DEFAULT true NOT NULL,
"last_synced_at" timestamp with time zone,
"last_error" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "document_import_states" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"source_id" uuid NOT NULL,
"mailbox_path" text NOT NULL,
"uid_validity" bigint,
"highest_uid" bigint DEFAULT 0 NOT NULL,
"delta_link_encrypted" jsonb,
"updated_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "document_import_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"tenant_id" bigint NOT NULL,
"source_id" uuid NOT NULL,
"remote_message_id" text NOT NULL,
"attachment_key" text NOT NULL,
"attachment_checksum" text NOT NULL,
"filename" text,
"status" text NOT NULL,
"error" text,
"file_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "document_import_sources" ADD CONSTRAINT "document_import_sources_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;
--> statement-breakpoint
ALTER TABLE "document_import_sources" ADD CONSTRAINT "document_import_sources_created_by_auth_users_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."auth_users"("id") ON DELETE set null;
--> statement-breakpoint
ALTER TABLE "document_import_sources" ADD CONSTRAINT "document_import_sources_target_folder_id_folders_id_fk" FOREIGN KEY ("target_folder_id") REFERENCES "public"."folders"("id") ON DELETE set null;
--> statement-breakpoint
ALTER TABLE "document_import_sources" ADD CONSTRAINT "document_import_sources_default_filetype_id_filetags_id_fk" FOREIGN KEY ("default_filetype_id") REFERENCES "public"."filetags"("id") ON DELETE set null;
--> statement-breakpoint
ALTER TABLE "document_import_states" ADD CONSTRAINT "document_import_states_source_id_document_import_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."document_import_sources"("id") ON DELETE cascade ON UPDATE cascade;
--> statement-breakpoint
ALTER TABLE "document_import_items" ADD CONSTRAINT "document_import_items_tenant_id_tenants_id_fk" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE cascade ON UPDATE cascade;
--> statement-breakpoint
ALTER TABLE "document_import_items" ADD CONSTRAINT "document_import_items_source_id_document_import_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."document_import_sources"("id") ON DELETE cascade ON UPDATE cascade;
--> statement-breakpoint
ALTER TABLE "document_import_items" ADD CONSTRAINT "document_import_items_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null;
--> statement-breakpoint
CREATE INDEX "document_import_sources_tenant_idx" ON "document_import_sources" USING btree ("tenant_id");
--> statement-breakpoint
CREATE UNIQUE INDEX "document_import_states_source_mailbox_key" ON "document_import_states" USING btree ("source_id", "mailbox_path");
--> statement-breakpoint
CREATE UNIQUE INDEX "document_import_items_remote_attachment_key" ON "document_import_items" USING btree ("source_id", "remote_message_id", "attachment_key");
--> statement-breakpoint
CREATE INDEX "document_import_items_checksum_idx" ON "document_import_items" USING btree ("source_id", "attachment_checksum");

View File

@@ -393,6 +393,13 @@
"when": 1786023052994,
"tag": "0058_global_reference_data",
"breakpoints": true
},
{
"idx": 56,
"version": "7",
"when": 1786082400000,
"tag": "0059_bankstatement_notes",
"breakpoints": true
}
]
}

View File

@@ -34,6 +34,7 @@ export const bankstatements = pgTable("bankstatements", {
credName: text("credName"),
text: text("text"),
notes: text("notes"),
amount: doublePrecision("amount").notNull(),
tenant: bigint("tenant", { mode: "number" })

View File

@@ -0,0 +1,84 @@
import {
bigint,
boolean,
index,
integer,
jsonb,
pgTable,
text,
timestamp,
uniqueIndex,
uuid,
} from "drizzle-orm/pg-core"
import { tenants } from "./tenants"
import { authUsers } from "./auth_users"
import { files } from "./files"
import { folders } from "./folders"
import { filetags } from "./filetags"
export const documentImportSources = pgTable("document_import_sources", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
createdBy: uuid("created_by").references(() => authUsers.id, { onDelete: "set null" }),
name: text("name").notNull(),
provider: text("provider").notNull().default("imap"),
enabled: boolean("enabled").notNull().default(true),
mailboxAddressEncrypted: jsonb("mailbox_address_encrypted"),
passwordEncrypted: jsonb("password_encrypted"),
imapHostEncrypted: jsonb("imap_host_encrypted"),
imapPort: integer("imap_port").notNull().default(993),
imapSecure: boolean("imap_secure").notNull().default(true),
mailboxPath: text("mailbox_path").notNull().default("INBOX"),
targetFolderId: uuid("target_folder_id").references(() => folders.id, { onDelete: "set null" }),
defaultFiletypeId: uuid("default_filetype_id").references(() => filetags.id, { onDelete: "set null" }),
markAsSeen: boolean("mark_as_seen").notNull().default(true),
lastSyncedAt: timestamp("last_synced_at", { withTimezone: true }),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }),
}, (table) => ({
tenantIdx: index("document_import_sources_tenant_idx").on(table.tenantId),
}))
export const documentImportStates = pgTable("document_import_states", {
id: uuid("id").primaryKey().defaultRandom(),
sourceId: uuid("source_id")
.notNull()
.references(() => documentImportSources.id, { onDelete: "cascade", onUpdate: "cascade" }),
mailboxPath: text("mailbox_path").notNull(),
uidValidity: bigint("uid_validity", { mode: "number" }),
highestUid: bigint("highest_uid", { mode: "number" }).notNull().default(0),
deltaLinkEncrypted: jsonb("delta_link_encrypted"),
updatedAt: timestamp("updated_at", { withTimezone: true }),
}, (table) => ({
sourceMailboxKey: uniqueIndex("document_import_states_source_mailbox_key")
.on(table.sourceId, table.mailboxPath),
}))
export const documentImportItems = pgTable("document_import_items", {
id: uuid("id").primaryKey().defaultRandom(),
tenantId: bigint("tenant_id", { mode: "number" })
.notNull()
.references(() => tenants.id, { onDelete: "cascade", onUpdate: "cascade" }),
sourceId: uuid("source_id")
.notNull()
.references(() => documentImportSources.id, { onDelete: "cascade", onUpdate: "cascade" }),
remoteMessageId: text("remote_message_id").notNull(),
attachmentKey: text("attachment_key").notNull(),
attachmentChecksum: text("attachment_checksum").notNull(),
filename: text("filename"),
status: text("status").notNull(),
error: text("error"),
fileId: uuid("file_id").references(() => files.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => ({
remoteAttachmentKey: uniqueIndex("document_import_items_remote_attachment_key")
.on(table.sourceId, table.remoteMessageId, table.attachmentKey),
checksumIdx: index("document_import_items_checksum_idx")
.on(table.sourceId, table.attachmentChecksum),
}))
export type DocumentImportSource = typeof documentImportSources.$inferSelect

View File

@@ -28,6 +28,7 @@ export * from "./customerspaces"
export * from "./customerinventoryitems"
export * from "./devices"
export * from "./documentboxes"
export * from "./document_imports"
export * from "./emails"
export * from "./enums"
export * from "./events"

View File

@@ -35,6 +35,7 @@ import communicationRoutes from "./routes/communication";
import telephonyRoutes from "./routes/telephony";
import instanceAgentRoutes from "./routes/instanceAgents";
import instanceAgentGatewayRoutes from "./routes/instanceAgentGateway";
import documentImportRoutes from "./routes/documentImports";
//Public Links
import publiclinksNonAuthenticatedRoutes from "./routes/publiclinks/publiclinks-non-authenticated";
@@ -62,6 +63,7 @@ import {initS3} from "./utils/s3";
import { runBootstrap } from "./modules/bootstrap.service";
import { startMatrixPushWorker } from "./modules/matrix-push-worker.service";
import { startCentralServicesHeartbeat } from "./modules/central-services-heartbeat.service";
import { startDocumentImportWorker } from "./modules/document-import/document-import.worker";
//Services
@@ -89,6 +91,7 @@ async function main() {
await runBootstrap(app);
startMatrixPushWorker(app);
startCentralServicesHeartbeat(app);
startDocumentImportWorker(app);
app.addHook('preHandler', (req, reply, done) => {
console.log(req.method)
@@ -167,6 +170,7 @@ async function main() {
await subApp.register(communicationRoutes);
await subApp.register(telephonyRoutes);
await subApp.register(instanceAgentRoutes);
await subApp.register(documentImportRoutes);
},{prefix: "/api"})

View File

@@ -0,0 +1,307 @@
import { createHash } from "node:crypto"
import dayjs from "dayjs"
import { and, eq } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { ImapFlow } from "imapflow"
import { simpleParser } from "mailparser"
import {
documentImportItems,
documentImportSources,
documentImportStates,
filetags,
folders,
} from "../../../db/schema"
import { decrypt } from "../../utils/crypt"
import { saveFile } from "../../utils/files"
type ImportSourceConnection = {
id: string
tenantId: number
name: string
provider: string
enabled: boolean
mailboxAddress: string
password: string
imapHost: string
imapPort: number
imapSecure: boolean
mailboxPath: string
targetFolderId: string | null
defaultFiletypeId: string | null
markAsSeen: boolean
}
const activeSyncs = new Set<string>()
const decryptString = (value: unknown) => value ? decrypt(value as any) : ""
export function documentImportService(server: FastifyInstance) {
const loadSource = async (tenantId: number, sourceId: string): Promise<ImportSourceConnection | null> => {
const [row] = await server.db
.select()
.from(documentImportSources)
.where(and(
eq(documentImportSources.id, sourceId),
eq(documentImportSources.tenantId, tenantId),
))
.limit(1)
if (!row) return null
return {
id: row.id,
tenantId: row.tenantId,
name: row.name,
provider: row.provider,
enabled: row.enabled,
mailboxAddress: decryptString(row.mailboxAddressEncrypted),
password: decryptString(row.passwordEncrypted),
imapHost: decryptString(row.imapHostEncrypted),
imapPort: row.imapPort,
imapSecure: row.imapSecure,
mailboxPath: row.mailboxPath,
targetFolderId: row.targetFolderId,
defaultFiletypeId: row.defaultFiletypeId,
markAsSeen: row.markAsSeen,
}
}
const createImapClient = (source: ImportSourceConnection) => new ImapFlow({
host: source.imapHost,
port: source.imapPort,
secure: source.imapSecure,
auth: { user: source.mailboxAddress, pass: source.password },
logger: false,
})
const classify = async (source: ImportSourceConnection, subject = "") => {
let folderId = source.targetFolderId
let filetypeId = source.defaultFiletypeId
if (!folderId && /(Rechnung|Beleg|Invoice|Quittung)/i.test(subject)) {
const [folder] = await server.db.select({ id: folders.id }).from(folders).where(and(
eq(folders.tenant, source.tenantId),
eq(folders.function, "incomingInvoices"),
// @ts-ignore Das bestehende Schema typisiert das Jahr numerisch, verwendet es aber als Zeichenfolge.
eq(folders.year, dayjs().format("YYYY")),
)).limit(1)
folderId = folder?.id || null
if (!filetypeId) {
const [tag] = await server.db.select({ id: filetags.id }).from(filetags).where(and(
eq(filetags.tenant, source.tenantId),
eq(filetags.incomingDocumentType, "invoices"),
)).limit(1)
filetypeId = tag?.id || null
}
} else if (!filetypeId && /(Mahnung|Zahlungsaufforderung|Zahlungsverzug)/i.test(subject)) {
const [tag] = await server.db.select({ id: filetags.id }).from(filetags).where(and(
eq(filetags.tenant, source.tenantId),
eq(filetags.incomingDocumentType, "reminders"),
)).limit(1)
filetypeId = tag?.id || null
}
if (!folderId) {
const [folder] = await server.db.select({ id: folders.id }).from(folders).where(and(
eq(folders.tenant, source.tenantId),
eq(folders.function, "deposit"),
)).limit(1)
folderId = folder?.id || null
}
return { folderId, filetypeId }
}
const importAttachment = async (
source: ImportSourceConnection,
remoteMessageId: string,
subject: string,
attachment: any,
index: number,
) => {
const content = Buffer.from(attachment.content)
const checksum = createHash("sha256").update(content).digest("hex")
const attachmentKey = `${index}:${attachment.filename || "Anhang"}`
const [existing] = await server.db.select({ status: documentImportItems.status }).from(documentImportItems)
.where(and(
eq(documentImportItems.sourceId, source.id),
eq(documentImportItems.remoteMessageId, remoteMessageId),
eq(documentImportItems.attachmentKey, attachmentKey),
)).limit(1)
if (existing?.status === "imported" || existing?.status === "duplicate") return "duplicate"
const [sameContent] = await server.db.select({ id: documentImportItems.id }).from(documentImportItems)
.where(and(
eq(documentImportItems.sourceId, source.id),
eq(documentImportItems.attachmentChecksum, checksum),
eq(documentImportItems.status, "imported"),
)).limit(1)
if (sameContent) {
await server.db.insert(documentImportItems).values({
tenantId: source.tenantId,
sourceId: source.id,
remoteMessageId,
attachmentKey,
attachmentChecksum: checksum,
filename: attachment.filename || null,
status: "duplicate",
}).onConflictDoUpdate({
target: [documentImportItems.sourceId, documentImportItems.remoteMessageId, documentImportItems.attachmentKey],
set: { status: "duplicate", error: null },
})
return "duplicate"
}
try {
const target = await classify(source, subject)
const saved = await saveFile(
server,
source.tenantId,
remoteMessageId,
attachment,
target.folderId,
target.filetypeId,
)
if (!saved) throw new Error("Datei konnte nicht gespeichert werden")
await server.db.insert(documentImportItems).values({
tenantId: source.tenantId,
sourceId: source.id,
remoteMessageId,
attachmentKey,
attachmentChecksum: checksum,
filename: attachment.filename || null,
status: "imported",
fileId: saved.id,
}).onConflictDoUpdate({
target: [documentImportItems.sourceId, documentImportItems.remoteMessageId, documentImportItems.attachmentKey],
set: { status: "imported", error: null, fileId: saved.id },
})
return "imported"
} catch (error: any) {
await server.db.insert(documentImportItems).values({
tenantId: source.tenantId,
sourceId: source.id,
remoteMessageId,
attachmentKey,
attachmentChecksum: checksum,
filename: attachment.filename || null,
status: "failed",
error: error?.message || "Import fehlgeschlagen",
}).onConflictDoUpdate({
target: [documentImportItems.sourceId, documentImportItems.remoteMessageId, documentImportItems.attachmentKey],
set: { status: "failed", error: error?.message || "Import fehlgeschlagen" },
})
throw error
}
}
const testConnection = async (tenantId: number, sourceId: string) => {
const source = await loadSource(tenantId, sourceId)
if (!source) throw new Error("Importquelle wurde nicht gefunden")
if (source.provider !== "imap") throw new Error("Dieser Provider wird noch nicht unterstützt")
const client = createImapClient(source)
try {
await client.connect()
const mailbox = await client.mailboxOpen(source.mailboxPath, { readOnly: true })
return { success: true, mailbox: source.mailboxPath, messages: mailbox.exists }
} finally {
await client.logout().catch(() => client.close())
}
}
const syncSource = async (tenantId: number, sourceId: string) => {
if (activeSyncs.has(sourceId)) throw new Error("Diese Importquelle wird bereits synchronisiert")
const source = await loadSource(tenantId, sourceId)
if (!source) throw new Error("Importquelle wurde nicht gefunden")
if (!source.enabled) throw new Error("Importquelle ist deaktiviert")
if (source.provider !== "imap") throw new Error("Dieser Provider wird noch nicht unterstützt")
activeSyncs.add(sourceId)
const client = createImapClient(source)
let imported = 0
let duplicates = 0
let messages = 0
try {
await client.connect()
const lock = await client.getMailboxLock(source.mailboxPath)
try {
const opened: any = await client.mailboxOpen(source.mailboxPath)
const uidValidity = Number(opened.uidValidity || 0)
const [state] = await server.db.select().from(documentImportStates).where(and(
eq(documentImportStates.sourceId, source.id),
eq(documentImportStates.mailboxPath, source.mailboxPath),
)).limit(1)
const highestUid = state && Number(state.uidValidity) === uidValidity ? Number(state.highestUid) : 0
let processedHighestUid = highestUid
// Ungelesene Nachrichten werden immer berücksichtigt. So bleibt ein fehlgeschlagener
// Import erneut verarbeitbar, auch wenn danach bereits neuere UIDs erfolgreich waren.
const query: any = { seen: false }
for await (const message of client.fetch(query, { uid: true, envelope: true, source: true })) {
messages += 1
const parsed = await simpleParser(message.source)
const remoteMessageId = `${uidValidity}:${message.uid}`
let complete = true
for (const [index, attachment] of (parsed.attachments || []).entries()) {
if (attachment.contentDisposition === "inline" && !attachment.filename) continue
try {
const result = await importAttachment(source, remoteMessageId, parsed.subject || "", attachment, index)
if (result === "imported") imported += 1
else duplicates += 1
} catch {
complete = false
}
}
if (complete && source.markAsSeen) await client.messageFlagsAdd({ uid: message.uid }, ["\\Seen"], { uid: true })
if (complete) processedHighestUid = Math.max(processedHighestUid, Number(message.uid))
}
await server.db.insert(documentImportStates).values({
sourceId: source.id,
mailboxPath: source.mailboxPath,
uidValidity,
highestUid: processedHighestUid,
updatedAt: new Date(),
}).onConflictDoUpdate({
target: [documentImportStates.sourceId, documentImportStates.mailboxPath],
set: { uidValidity, highestUid: processedHighestUid, updatedAt: new Date() },
})
} finally {
lock.release()
}
await server.db.update(documentImportSources).set({ lastSyncedAt: new Date(), lastError: null }).where(eq(documentImportSources.id, source.id))
return { success: true, messages, imported, duplicates }
} catch (error: any) {
await server.db.update(documentImportSources).set({ lastError: error?.message || "Synchronisierung fehlgeschlagen" }).where(eq(documentImportSources.id, source.id))
throw error
} finally {
activeSyncs.delete(sourceId)
if (client.usable) await client.logout().catch(() => client.close())
}
}
const syncAll = async () => {
const sources = await server.db.select({
id: documentImportSources.id,
tenantId: documentImportSources.tenantId,
}).from(documentImportSources).where(eq(documentImportSources.enabled, true))
const results = []
for (const source of sources) {
try {
results.push({ sourceId: source.id, ...(await syncSource(source.tenantId, source.id)) })
} catch (error: any) {
server.log.error({ sourceId: source.id, error: error?.message }, "Dokumentenimport fehlgeschlagen")
results.push({ sourceId: source.id, success: false, error: error?.message || "Import fehlgeschlagen" })
}
}
return results
}
return { testConnection, syncSource, syncAll }
}

View File

@@ -0,0 +1,13 @@
import { FastifyInstance } from "fastify"
const SYNC_INTERVAL_MS = 5 * 60 * 1000
export function startDocumentImportWorker(server: FastifyInstance) {
const run = () => server.services.documentImports.syncAll().catch((error) => {
server.log.error({ error }, "Automatischer Dokumentenimport fehlgeschlagen")
})
const timer = setInterval(run, SYNC_INTERVAL_MS)
timer.unref()
server.addHook("onClose", async () => clearInterval(timer))
}

View File

@@ -4,6 +4,7 @@ import { bankStatementService } from "../modules/cron/bankstatementsync.service"
import {syncDokuboxService} from "../modules/cron/dokuboximport.service";
import { FastifyInstance } from "fastify";
import {prepareIncomingInvoices} from "../modules/cron/prepareIncomingInvoices";
import {documentImportService} from "../modules/document-import/document-import.service";
declare module "fastify" {
interface FastifyInstance {
@@ -11,6 +12,7 @@ declare module "fastify" {
bankStatements: ReturnType<typeof bankStatementService>;
dokuboxSync: ReturnType<typeof syncDokuboxService>;
prepareIncomingInvoices: ReturnType<typeof prepareIncomingInvoices>;
documentImports: ReturnType<typeof documentImportService>;
};
}
}
@@ -20,5 +22,6 @@ export default fp(async function servicePlugin(server: FastifyInstance) {
bankStatements: bankStatementService(server),
dokuboxSync: syncDokuboxService(server),
prepareIncomingInvoices: prepareIncomingInvoices(server),
documentImports: documentImportService(server),
});
});

View File

@@ -0,0 +1,118 @@
import { and, desc, eq } from "drizzle-orm"
import { FastifyInstance } from "fastify"
import { documentImportItems, documentImportSources } from "../../db/schema"
import { decrypt, encrypt } from "../utils/crypt"
const decrypted = (value: unknown) => value ? decrypt(value as any) : null
export default async function documentImportRoutes(server: FastifyInstance) {
const tenantId = (req: any) => {
if (!req.user?.tenant_id) throw new Error("Kein aktiver Mandant")
return Number(req.user.tenant_id)
}
const response = (row: any) => ({
id: row.id,
name: row.name,
provider: row.provider,
enabled: row.enabled,
mailboxAddress: decrypted(row.mailboxAddressEncrypted),
imapHost: decrypted(row.imapHostEncrypted),
imapPort: row.imapPort,
imapSecure: row.imapSecure,
mailboxPath: row.mailboxPath,
targetFolderId: row.targetFolderId,
defaultFiletypeId: row.defaultFiletypeId,
markAsSeen: row.markAsSeen,
hasPassword: Boolean(row.passwordEncrypted),
lastSyncedAt: row.lastSyncedAt,
lastError: row.lastError,
createdAt: row.createdAt,
})
server.get("/document-imports", async (req) => {
const rows = await server.db.select().from(documentImportSources)
.where(eq(documentImportSources.tenantId, tenantId(req)))
.orderBy(documentImportSources.name)
return rows.map(response)
})
server.get("/document-imports/:id", async (req, reply) => {
const { id } = req.params as { id: string }
const [row] = await server.db.select().from(documentImportSources).where(and(
eq(documentImportSources.id, id),
eq(documentImportSources.tenantId, tenantId(req)),
)).limit(1)
if (!row) return reply.code(404).send({ error: "Importquelle wurde nicht gefunden" })
return response(row)
})
server.post("/document-imports/:id?", async (req, reply) => {
const currentTenantId = tenantId(req)
const { id } = req.params as { id?: string }
const body = (req.body || {}) as any
if (!body.name?.trim()) return reply.code(400).send({ error: "Name fehlt" })
if ((body.provider || "imap") !== "imap") return reply.code(400).send({ error: "Aktuell wird nur IMAP unterstützt" })
if (!body.mailboxAddress || !body.imapHost) return reply.code(400).send({ error: "Postfachadresse und IMAP-Host sind erforderlich" })
const values: any = {
name: body.name.trim(),
provider: "imap",
enabled: body.enabled !== false,
mailboxAddressEncrypted: encrypt(body.mailboxAddress),
imapHostEncrypted: encrypt(body.imapHost),
imapPort: Number(body.imapPort || 993),
imapSecure: body.imapSecure !== false,
mailboxPath: body.mailboxPath?.trim() || "INBOX",
targetFolderId: body.targetFolderId || null,
defaultFiletypeId: body.defaultFiletypeId || null,
markAsSeen: body.markAsSeen !== false,
updatedAt: new Date(),
}
if (body.password) values.passwordEncrypted = encrypt(body.password)
if (id) {
const [existing] = await server.db.select({ id: documentImportSources.id }).from(documentImportSources).where(and(
eq(documentImportSources.id, id),
eq(documentImportSources.tenantId, currentTenantId),
)).limit(1)
if (!existing) return reply.code(404).send({ error: "Importquelle wurde nicht gefunden" })
await server.db.update(documentImportSources).set(values).where(eq(documentImportSources.id, id))
return { success: true, id }
}
if (!body.password) return reply.code(400).send({ error: "Passwort fehlt" })
const [created] = await server.db.insert(documentImportSources).values({
...values,
tenantId: currentTenantId,
createdBy: req.user.user_id,
}).returning({ id: documentImportSources.id })
return { success: true, id: created.id }
})
server.post("/document-imports/:id/test", async (req, reply) => {
try {
return await server.services.documentImports.testConnection(tenantId(req), (req.params as { id: string }).id)
} catch (error: any) {
return reply.code(400).send({ error: error?.message || "Verbindung fehlgeschlagen" })
}
})
server.post("/document-imports/:id/sync", async (req, reply) => {
try {
return await server.services.documentImports.syncSource(tenantId(req), (req.params as { id: string }).id)
} catch (error: any) {
return reply.code(400).send({ error: error?.message || "Synchronisierung fehlgeschlagen" })
}
})
server.get("/document-imports/:id/items", async (req) => {
const currentTenantId = tenantId(req)
const { id } = req.params as { id: string }
return server.db.select().from(documentImportItems).where(and(
eq(documentImportItems.tenantId, currentTenantId),
eq(documentImportItems.sourceId, id),
)).orderBy(desc(documentImportItems.createdAt)).limit(100)
})
}

View File

@@ -316,6 +316,11 @@ const links = computed(() => {
to: "/settings/emailaccounts",
icon: "i-heroicons-envelope",
} : null,
featureEnabled("files") ? {
label: "Dokumentenimporte",
to: "/settings/document-imports",
icon: "i-heroicons-inbox-arrow-down",
} : null,
featureEnabled("settingsBanking") ? {
label: "Bankkonten",
to: "/settings/banking",

View File

@@ -11,6 +11,7 @@ import {
} from "~/composables/useDepreciation"
const loading = ref(true)
const { loadCoreData } = useDashboardData()
const summary = ref({
label: "",
income: 0,
@@ -47,11 +48,11 @@ const loadSummary = async () => {
end: dayjs().endOf("month")
}
const [docs, incoming, allocations] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select(),
const [coreData, allocations] = await Promise.all([
loadCoreData(),
useEntities("statementallocations").select("*, bankstatement(*)")
])
const { createdDocuments: docs, incomingInvoices: incoming } = coreData
const outputDocs = (docs || []).filter((doc: any) => {
if (!isRelevantOutputDocument(doc)) {

View File

@@ -17,6 +17,7 @@ const props = defineProps({
})
const tempStore = useTempStore()
const { loadCoreData } = useDashboardData()
const isMounted = ref(false)
const amountMode = ref("net")
@@ -82,10 +83,7 @@ watch([amountMode, granularity, selectedYear, selectedMonth], () => {
})
const loadData = async () => {
const [docs, incoming] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
const { createdDocuments: docs, incomingInvoices: incoming } = await loadCoreData()
incomeDocuments.value = (docs || []).filter((item) => item.state === "Gebucht" && ["invoices", "advanceInvoices", "cancellationInvoices"].includes(item.type))
expenseInvoices.value = (incoming || []).filter((item) => item.state === "Gebucht" && item.date)

View File

@@ -13,6 +13,7 @@ import {
dayjs.extend(customParseFormat)
const auth = useAuthStore()
const { loadCoreData } = useDashboardData()
const loading = ref(true)
const summary = ref({
@@ -39,10 +40,7 @@ const loadSummary = async () => {
const periodType = normalizeTaxEvaluationPeriod(auth.activeTenantData?.taxEvaluationPeriod)
const bounds = getTaxEvaluationPeriodBounds(dayjs(), periodType)
const [docs, incoming] = await Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
const { createdDocuments: docs, incomingInvoices: incoming } = await loadCoreData()
const outputDocs = (docs || []).filter((doc: any) => {
if (doc?.state !== "Gebucht") return false

View File

@@ -0,0 +1,54 @@
type DashboardCoreData = {
createdDocuments: any[]
incomingInvoices: any[]
}
const CACHE_TTL_MS = 30_000
type DashboardCacheEntry = {
cachedAt: number
data: DashboardCoreData | null
pendingRequest: Promise<DashboardCoreData> | null
}
const tenantCaches = new Map<string, DashboardCacheEntry>()
/**
* Bündelt die großen, von mehreren Dashboard-Karten benötigten Abfragen.
* So werden identische Requests beim parallelen Mounten nur einmal ausgeführt.
*/
export const useDashboardData = () => {
const auth = useAuthStore()
const getTenantKey = () => String(auth.activeTenant || auth.activeTenantData?.id || "default")
const loadCoreData = async (force = false): Promise<DashboardCoreData> => {
const tenantKey = getTenantKey()
const cache = tenantCaches.get(tenantKey) || { cachedAt: 0, data: null, pendingRequest: null }
tenantCaches.set(tenantKey, cache)
const cacheIsFresh = cache.data && Date.now() - cache.cachedAt < CACHE_TTL_MS
if (!force && cacheIsFresh) return cache.data
if (!force && cache.pendingRequest) return cache.pendingRequest
cache.pendingRequest = Promise.all([
useEntities("createddocuments").select(),
useEntities("incominginvoices").select()
])
.then(([createdDocuments, incomingInvoices]) => {
cache.data = {
createdDocuments: createdDocuments || [],
incomingInvoices: incomingInvoices || []
}
cache.cachedAt = Date.now()
return cache.data
})
.finally(() => {
cache.pendingRequest = null
})
return cache.pendingRequest
}
return { loadCoreData }
}

View File

@@ -39,6 +39,7 @@ const ownaccounts = ref([])
const loading = ref(true)
const loadingDocuments = ref(true)
const savingNotes = ref(false)
const rebuildDocumentLists = () => {
const documents = createddocuments.value.filter(i => i.type === "invoices" || i.type === "advanceInvoices")
@@ -260,6 +261,22 @@ const removeAllocation = async (allocationId) => {
manualAllocationSum.value = calculateOpenSum.value
}
const saveNotes = async () => {
savingNotes.value = true
try {
const notes = String(itemInfo.value.notes || "").trim() || null
const updated = await useEntities("bankstatements").update(itemInfo.value.id, {notes}, true)
itemInfo.value.notes = updated.notes
oldItemInfo.value.notes = updated.notes
} finally {
savingNotes.value = false
}
}
const notesChanged = computed(() =>
String(itemInfo.value?.notes || "").trim() !== String(oldItemInfo.value?.notes || "").trim()
)
const searchString = ref(tempStore.searchStrings["bankstatementsedit"] || '')
const clearSearchString = () => {
@@ -595,6 +612,28 @@ setup()
</div>
</div>
<div class="mt-3">
<UFormField label="Notiz" size="sm">
<UTextarea
v-model="itemInfo.notes"
:rows="3"
autoresize
placeholder="Interne Notiz zu dieser Bankbuchung hinzufügen …"
/>
</UFormField>
<div class="mt-2 flex justify-end">
<UButton
icon="i-heroicons-check"
size="sm"
:disabled="!notesChanged"
:loading="savingNotes"
@click="saveNotes"
>
Notiz speichern
</UButton>
</div>
</div>
<div class="mt-4">
<div class="flex justify-between text-xs mb-1 font-medium">
<span :class="calculateOpenSum != 0 ? 'text-amber-600' : 'text-green-600'">

View File

@@ -2,13 +2,13 @@
import { setPageLayout } from "#app"
import "gridstack/dist/gridstack.min.css"
import DisplayIncomeAndExpenditure from "~/components/displayIncomeAndExpenditure.vue"
import DisplayOpenBalances from "~/components/displayOpenBalances.vue"
import DisplayBankaccounts from "~/components/displayBankaccounts.vue"
import DisplayProjectsInPhases from "~/components/displayProjectsInPhases.vue"
import DisplayOpenTasks from "~/components/displayOpenTasks.vue"
import DisplayTaxSummary from "~/components/displayTaxSummary.vue"
import DisplayBWASummary from "~/components/displayBWASummary.vue"
const DisplayIncomeAndExpenditure = defineAsyncComponent(() => import("~/components/displayIncomeAndExpenditure.vue"))
const DisplayOpenBalances = defineAsyncComponent(() => import("~/components/displayOpenBalances.vue"))
const DisplayBankaccounts = defineAsyncComponent(() => import("~/components/displayBankaccounts.vue"))
const DisplayProjectsInPhases = defineAsyncComponent(() => import("~/components/displayProjectsInPhases.vue"))
const DisplayOpenTasks = defineAsyncComponent(() => import("~/components/displayOpenTasks.vue"))
const DisplayTaxSummary = defineAsyncComponent(() => import("~/components/displayTaxSummary.vue"))
const DisplayBWASummary = defineAsyncComponent(() => import("~/components/displayBWASummary.vue"))
setPageLayout("default")

View File

@@ -43,7 +43,8 @@ const generateLink = async (bankId) => {
}
})
} catch (error) {
console.log(error)
console.error(error)
toast.add({title: "Die Bankverbindung konnte nicht gestartet werden", color: "error"})
}
}
@@ -136,7 +137,7 @@ setupPage()
color="primary"
variant="outline"
class="mt-3"
:actions="[{ variant: 'solid', color: 'primary', label: 'Verbinden',click: generateLink }]"
:actions="[{ variant: 'solid', color: 'primary', label: 'Verbinden', onClick: () => generateLink() }]"
/>
<UAlert
v-else-if="showAlert && !bankData.id"

View File

@@ -0,0 +1,219 @@
<script setup lang="ts">
type ImportSource = {
id: string
name: string
provider: string
enabled: boolean
mailboxAddress: string
imapHost: string
imapPort: number
imapSecure: boolean
mailboxPath: string
markAsSeen: boolean
hasPassword: boolean
lastSyncedAt?: string | null
lastError?: string | null
}
const api = useNuxtApp().$api
const toast = useToast()
const sources = ref<ImportSource[]>([])
const loading = ref(true)
const saving = ref(false)
const activeAction = ref<string | null>(null)
const editingId = ref<string | null>(null)
const showForm = ref(false)
const emptyForm = () => ({
name: "",
provider: "imap",
enabled: true,
mailboxAddress: "",
password: "",
imapHost: "",
imapPort: 993,
imapSecure: true,
mailboxPath: "INBOX",
markAsSeen: true,
})
const form = ref(emptyForm())
const load = async () => {
loading.value = true
try {
sources.value = await api("/api/document-imports")
} finally {
loading.value = false
}
}
const create = () => {
editingId.value = null
form.value = emptyForm()
showForm.value = true
}
const edit = (source: ImportSource) => {
editingId.value = source.id
form.value = {
name: source.name,
provider: source.provider,
enabled: source.enabled,
mailboxAddress: source.mailboxAddress,
password: "",
imapHost: source.imapHost,
imapPort: source.imapPort,
imapSecure: source.imapSecure,
mailboxPath: source.mailboxPath,
markAsSeen: source.markAsSeen,
}
showForm.value = true
}
const save = async () => {
saving.value = true
try {
const result = await api(`/api/document-imports${editingId.value ? `/${editingId.value}` : ""}`, {
method: "POST",
body: { ...form.value, imapPort: Number(form.value.imapPort), password: form.value.password || undefined },
})
toast.add({ title: "Importquelle gespeichert", color: "success" })
showForm.value = false
await load()
if (!editingId.value && result.id) await runAction(result.id, "test")
} catch (error: any) {
toast.add({ title: "Speichern fehlgeschlagen", description: error?.data?.error || error?.message, color: "error" })
} finally {
saving.value = false
}
}
const runAction = async (id: string, action: "test" | "sync") => {
activeAction.value = `${id}:${action}`
try {
const result = await api(`/api/document-imports/${id}/${action}`, { method: "POST" })
toast.add({
title: action === "test" ? "Verbindung erfolgreich" : "Import abgeschlossen",
description: action === "test"
? `${result.messages} Nachrichten im Postfach`
: `${result.imported} Anhänge importiert, ${result.duplicates} Dubletten übersprungen`,
color: "success",
})
await load()
} catch (error: any) {
toast.add({
title: action === "test" ? "Verbindung fehlgeschlagen" : "Import fehlgeschlagen",
description: error?.data?.error || error?.message,
color: "error",
})
await load()
} finally {
activeAction.value = null
}
}
onMounted(load)
</script>
<template>
<UDashboardNavbar title="Dokumentenimporte">
<template #right>
<UButton icon="i-heroicons-plus" @click="create">Importquelle</UButton>
</template>
</UDashboardNavbar>
<div class="space-y-5 p-4">
<UAlert
color="neutral"
variant="soft"
icon="i-heroicons-inbox-arrow-down"
title="Dokumente aus eigenen Postfächern importieren"
description="FEDEO importiert Dateianhänge aus ungelesenen Nachrichten. Nachrichten werden erst nach erfolgreicher Verarbeitung als gelesen markiert und nicht gelöscht."
/>
<div v-if="loading" class="space-y-3">
<USkeleton v-for="index in 3" :key="index" class="h-28" />
</div>
<TableEmptyState v-else-if="sources.length === 0" label="Noch keine Importquelle eingerichtet" />
<div v-else class="space-y-3">
<div v-for="source in sources" :key="source.id" class="rounded-lg border border-(--ui-border) p-4">
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<p class="font-medium">{{ source.name }}</p>
<UBadge color="neutral" variant="soft">IMAP</UBadge>
<UBadge :color="source.enabled ? 'success' : 'neutral'" variant="soft">
{{ source.enabled ? "Aktiv" : "Deaktiviert" }}
</UBadge>
</div>
<p class="mt-1 truncate text-sm text-dimmed">
{{ source.mailboxAddress }} · {{ source.imapHost }}:{{ source.imapPort }} · {{ source.mailboxPath }}
</p>
<p v-if="source.lastSyncedAt" class="mt-1 text-xs text-dimmed">
Zuletzt synchronisiert: {{ new Date(source.lastSyncedAt).toLocaleString('de-DE') }}
</p>
<p v-if="source.lastError" class="mt-1 text-sm text-error">{{ source.lastError }}</p>
</div>
<div class="flex shrink-0 flex-wrap gap-2">
<UButton
color="neutral"
variant="soft"
icon="i-heroicons-signal"
:loading="activeAction === `${source.id}:test`"
@click="runAction(source.id, 'test')"
>Testen</UButton>
<UButton
icon="i-heroicons-arrow-path"
:loading="activeAction === `${source.id}:sync`"
:disabled="!source.enabled"
@click="runAction(source.id, 'sync')"
>Jetzt importieren</UButton>
<UButton color="neutral" variant="ghost" icon="i-heroicons-pencil-square" @click="edit(source)" />
</div>
</div>
</div>
</div>
<UModal v-model:open="showForm" :title="editingId ? 'Importquelle bearbeiten' : 'Importquelle einrichten'">
<template #body>
<UForm class="space-y-5" @submit.prevent="save">
<div class="grid gap-4 md:grid-cols-2">
<UFormField label="Bezeichnung" required>
<UInput v-model="form.name" placeholder="Rechnungseingang" class="w-full" />
</UFormField>
<UFormField label="Postfachadresse" required>
<UInput v-model="form.mailboxAddress" type="email" placeholder="rechnungen@firma.de" class="w-full" />
</UFormField>
</div>
<div class="grid gap-4 md:grid-cols-[1fr_120px]">
<UFormField label="IMAP-Host" required>
<UInput v-model="form.imapHost" placeholder="imap.example.de" class="w-full" />
</UFormField>
<UFormField label="Port" required>
<UInput v-model="form.imapPort" type="number" class="w-full" />
</UFormField>
</div>
<div class="grid gap-4 md:grid-cols-2">
<UFormField :label="editingId ? 'Neues Passwort' : 'Passwort'" :required="!editingId">
<UInput v-model="form.password" type="password" :placeholder="editingId ? 'Unverändert lassen' : ''" class="w-full" />
</UFormField>
<UFormField label="Postfachordner">
<UInput v-model="form.mailboxPath" placeholder="INBOX" class="w-full" />
</UFormField>
</div>
<div class="grid gap-3 md:grid-cols-3">
<UCheckbox v-model="form.enabled" label="Import aktiv" />
<UCheckbox v-model="form.imapSecure" label="TLS/SSL verwenden" />
<UCheckbox v-model="form.markAsSeen" label="Erfolgreiche E-Mails als gelesen markieren" />
</div>
<div class="flex justify-end gap-2">
<UButton color="neutral" variant="ghost" @click="showForm = false">Abbrechen</UButton>
<UButton type="submit" icon="i-heroicons-check" :loading="saving" :disabled="!form.name || !form.mailboxAddress || !form.imapHost || (!editingId && !form.password)">
Speichern
</UButton>
</div>
</UForm>
</template>
</UModal>
</div>
</template>