feat(documents): file scanner inbox documents with folder and type
All checks were successful
Build and Release FEDEO / verify (push) Successful in 1m0s
Build and Release FEDEO / build-and-release (push) Successful in 5m56s

This commit is contained in:
2026-09-29 16:48:15 +02:00
parent 2192fa5bb4
commit 052a3a8446
9 changed files with 400 additions and 14 deletions

View File

@@ -1,8 +1,8 @@
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto"
import { and, count, desc, eq, ilike, sql } from "drizzle-orm"
import { and, asc, count, desc, eq, ilike, isNull, sql } from "drizzle-orm"
import type { FastifyInstance } from "fastify"
import { DeleteObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"
import { files, scannerInboxConnections, scannerInboxDocuments, tenants } from "../../db/schema"
import { files, filetags, folders, scannerInboxConnections, scannerInboxDocuments, tenants } from "../../db/schema"
import { sanitizeFilename } from "../utils/filename"
import { s3 } from "../utils/s3"
import { secrets } from "../utils/secrets"
@@ -84,6 +84,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
}
const list = async (tenantId: number, limit: number, offset: number, search: string) => {
const condition = and(eq(scannerInboxDocuments.tenantId, tenantId), eq(files.tenant, tenantId),
isNull(scannerInboxDocuments.filedAt), eq(files.archived, false),
search ? ilike(scannerInboxDocuments.remoteFilename, `%${search.replace(/[\\%_]/g, "\\$&")}%`) : undefined)
const documents = await db.select({ id: files.id, name: scannerInboxDocuments.remoteFilename,
mimeType: files.mimeType, size: files.size, receivedAt: scannerInboxDocuments.receivedAt })
@@ -93,6 +94,47 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
.innerJoin(files, eq(files.id, scannerInboxDocuments.fileId)).where(condition)
return { documents, total: total.value }
}
const filingOptions = async (tenantId: number) => {
const [availableFolders, types] = await Promise.all([
db.select({ id: folders.id, name: folders.name, parent: folders.parent,
standardFiletype: folders.standardFiletype, standardFiletypeIsOptional: folders.standardFiletypeIsOptional })
.from(folders).where(and(eq(folders.tenant, tenantId), eq(folders.archived, false))).orderBy(asc(folders.name)),
db.select({ id: filetags.id, name: filetags.name }).from(filetags)
.where(and(eq(filetags.tenant, tenantId), eq(filetags.archived, false))).orderBy(asc(filetags.name)),
])
return { folders: availableFolders, types }
}
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")
if (!tenant) throw scannerError(404, "Mandant nicht gefunden.")
if (tenant.locked === "maintenance_tenant") throw scannerError(423, "Der Mandant wird gerade gewartet.")
const [entry] = await tx.select({ filedAt: scannerInboxDocuments.filedAt }).from(scannerInboxDocuments)
.where(and(eq(scannerInboxDocuments.tenantId, tenantId), eq(scannerInboxDocuments.fileId, fileId))).for("update")
if (!entry) throw scannerError(404, "Dokument nicht in der Ablage gefunden.")
if (entry.filedAt) throw scannerError(409, "Dieses Dokument wurde bereits abgelegt. Bitte den Eingang aktualisieren.")
const [file] = await tx.select({ id: files.id }).from(files)
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId), eq(files.archived, false))).for("update")
if (!file) throw scannerError(404, "Dokument nicht gefunden oder archiviert.")
if (folderId) {
const [folder] = await tx.select({ standardFiletype: folders.standardFiletype, optional: folders.standardFiletypeIsOptional })
.from(folders).where(and(eq(folders.id, folderId), eq(folders.tenant, tenantId), eq(folders.archived, false))).for("share")
if (!folder) throw scannerError(400, "Der gewählte Ordner ist nicht verfügbar.")
if (folder.standardFiletype && !folder.optional && folder.standardFiletype !== typeId) {
throw scannerError(400, "Dieser Ordner gibt einen festen Dokumenttyp vor.")
}
}
const [type] = await tx.select({ id: filetags.id }).from(filetags)
.where(and(eq(filetags.id, typeId), eq(filetags.tenant, tenantId), eq(filetags.archived, false))).for("share")
if (!type) throw scannerError(400, "Der gewählte Dokumenttyp ist nicht verfügbar.")
const now = new Date()
await tx.update(files).set({ folder: folderId, type: typeId, updatedAt: now, updatedBy: userId })
.where(and(eq(files.id, fileId), eq(files.tenant, tenantId)))
// Keep the receipt so a scanner retry cannot recreate a document after filing.
await tx.update(scannerInboxDocuments).set({ filedAt: now })
.where(and(eq(scannerInboxDocuments.fileId, fileId), eq(scannerInboxDocuments.tenantId, tenantId)))
return { id: fileId, folder: folderId, type: typeId }
})
const accept = async (identity: ScannerIdentity, filename: string, body: Buffer, onlyIfNew = false) => {
const contentType = validateScannerFile(filename, body)
const checksum = digest(body)
@@ -136,7 +178,7 @@ export function createScannerInboxService(db: FastifyInstance["db"], storage: Sc
throw error
}
}
return { connection, credentials, disable, authenticate, find, list, accept }
return { connection, credentials, disable, authenticate, find, list, filingOptions, fileDocument, accept }
}
export type ScannerInboxService = ReturnType<typeof createScannerInboxService>