feat(documents): suggest inbox folder and type with AI
All checks were successful
Build and Release FEDEO / verify (push) Successful in 56s
Build and Release FEDEO / build-and-release (push) Successful in 5m54s

This commit is contained in:
2026-10-01 18:11:41 +02:00
parent 07adca417e
commit 23d49f7186
10 changed files with 597 additions and 109 deletions

View File

@@ -6,6 +6,7 @@ import { files, filetags, folders, scannerInboxConnections, scannerInboxDocument
import { sanitizeFilename } from "../utils/filename"
import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
import { suggestDocumentFiling, validateFilingSuggestion, type FilingSuggestionResult } from "./scanner-inbox-suggestions"
export const SCANNER_UPLOAD_LIMIT = 100 * 1024 * 1024
export const SCANNER_DAV_PATH = "/scanner-inbox/"
@@ -45,7 +46,8 @@ export function validateScannerFile(filename: string, body: Buffer) {
throw scannerError(415, "Bitte eine vollständige PDF-, JPEG-, PNG- oder TIFF-Datei übertragen.")
}
export function createScannerInboxService(db: FastifyInstance["db"], storage: ScannerStorage = objectStorage) {
export function createScannerInboxService(db: FastifyInstance["db"], storage: ScannerStorage = objectStorage, analyze = suggestDocumentFiling) {
const pendingSuggestions = new Map<string, Promise<FilingSuggestionResult>>()
const publicConnection = {
username: scannerInboxConnections.username,
active: scannerInboxConnections.active,
@@ -108,6 +110,30 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
])
return { folders: availableFolders, types }
}
const suggestionDocument = async (tenantId: number, fileId: string) => {
const [document] = await db.select({ path: files.path, name: scannerInboxDocuments.remoteFilename, mimeType: files.mimeType, size: files.size })
.from(scannerInboxDocuments).innerJoin(files, eq(files.id, scannerInboxDocuments.fileId))
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId), eq(files.id, fileId),
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false))).limit(1)
if (!document?.path) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
return document
}
const suggestFiling = async (tenantId: number, fileId: string): Promise<FilingSuggestionResult> => {
// Check tenant ownership before reading storage or submitting anything to the AI provider.
const document = await suggestionDocument(tenantId, fileId)
const key = `${tenantId}:${fileId}`
if (pendingSuggestions.has(key)) return pendingSuggestions.get(key)!
if (pendingSuggestions.size >= 4) throw scannerError(429, "Die KI verarbeitet gerade weitere Dokumente. Bitte gleich erneut versuchen.")
const operation = (async () => {
const result = await analyze(document, await filingOptions(tenantId))
await suggestionDocument(tenantId, fileId)
const suggestion = validateFilingSuggestion(result, await filingOptions(tenantId))
return { suggestion, message: suggestion ? undefined : result.message || "Keine passende Zuordnung verfügbar. Bitte manuell auswählen." }
})()
pendingSuggestions.set(key, operation)
try { return await operation }
finally { pendingSuggestions.delete(key) }
}
const fileDocument = async (tenantId: number, userId: string, fileId: string, folderId: string | null, typeId: string) => db.transaction(async tx => {
const [tenant] = await tx.select({ locked: tenants.locked }).from(tenants)
.where(eq(tenants.id, tenantId)).for("share")
@@ -199,7 +225,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
throw error
}
}
return { connection, credentials, disable, authenticate, find, list, filingOptions, fileDocument, probe, accept }
return { connection, credentials, disable, authenticate, find, list, filingOptions, suggestFiling, fileDocument, probe, accept }
}
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>