diff --git a/backend/db/migrations/0060_document_import_sources.sql b/backend/db/migrations/0060_document_import_sources.sql new file mode 100644 index 0000000..ba8f265 --- /dev/null +++ b/backend/db/migrations/0060_document_import_sources.sql @@ -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"); diff --git a/backend/db/schema/document_imports.ts b/backend/db/schema/document_imports.ts new file mode 100644 index 0000000..4808c95 --- /dev/null +++ b/backend/db/schema/document_imports.ts @@ -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 diff --git a/backend/db/schema/index.ts b/backend/db/schema/index.ts index 8f766d6..3afc23c 100644 --- a/backend/db/schema/index.ts +++ b/backend/db/schema/index.ts @@ -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" diff --git a/backend/src/index.ts b/backend/src/index.ts index 0c43144..cdaf991 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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"}) diff --git a/backend/src/modules/document-import/document-import.service.ts b/backend/src/modules/document-import/document-import.service.ts new file mode 100644 index 0000000..0311ab8 --- /dev/null +++ b/backend/src/modules/document-import/document-import.service.ts @@ -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() + +const decryptString = (value: unknown) => value ? decrypt(value as any) : "" + +export function documentImportService(server: FastifyInstance) { + const loadSource = async (tenantId: number, sourceId: string): Promise => { + 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 } +} diff --git a/backend/src/modules/document-import/document-import.worker.ts b/backend/src/modules/document-import/document-import.worker.ts new file mode 100644 index 0000000..8d26fad --- /dev/null +++ b/backend/src/modules/document-import/document-import.worker.ts @@ -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)) +} diff --git a/backend/src/plugins/services.ts b/backend/src/plugins/services.ts index e170c49..b1868cd 100644 --- a/backend/src/plugins/services.ts +++ b/backend/src/plugins/services.ts @@ -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; dokuboxSync: ReturnType; prepareIncomingInvoices: ReturnType; + documentImports: ReturnType; }; } } @@ -20,5 +22,6 @@ export default fp(async function servicePlugin(server: FastifyInstance) { bankStatements: bankStatementService(server), dokuboxSync: syncDokuboxService(server), prepareIncomingInvoices: prepareIncomingInvoices(server), + documentImports: documentImportService(server), }); }); diff --git a/backend/src/routes/documentImports.ts b/backend/src/routes/documentImports.ts new file mode 100644 index 0000000..4083a1e --- /dev/null +++ b/backend/src/routes/documentImports.ts @@ -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) + }) +} diff --git a/frontend/components/MainNav.vue b/frontend/components/MainNav.vue index dcdb26a..0ae9b24 100644 --- a/frontend/components/MainNav.vue +++ b/frontend/components/MainNav.vue @@ -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", diff --git a/frontend/pages/settings/document-imports/index.vue b/frontend/pages/settings/document-imports/index.vue new file mode 100644 index 0000000..df196b0 --- /dev/null +++ b/frontend/pages/settings/document-imports/index.vue @@ -0,0 +1,219 @@ + + +