feat(documents): file scanner inbox documents with folder and type
This commit is contained in:
1
backend/db/migrations/0074_scanner_inbox_filing.sql
Normal file
1
backend/db/migrations/0074_scanner_inbox_filing.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "scanner_inbox_documents" ADD COLUMN "filed_at" timestamp with time zone;
|
||||
@@ -498,6 +498,13 @@
|
||||
"when": 1790640000000,
|
||||
"tag": "0073_scanner_document_inbox",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 71,
|
||||
"version": "7",
|
||||
"when": 1790640000001,
|
||||
"tag": "0074_scanner_inbox_filing",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const scannerInboxDocuments = pgTable("scanner_inbox_documents", {
|
||||
remoteFilename: text("remote_filename").notNull(),
|
||||
checksum: text("checksum").notNull(),
|
||||
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
filedAt: timestamp("filed_at", { withTimezone: true }),
|
||||
}, table => ({
|
||||
filenameKey: uniqueIndex("scanner_inbox_documents_filename_key").on(table.tenantId, table.remoteFilename),
|
||||
receivedIdx: index("scanner_inbox_documents_received_idx").on(table.tenantId, table.receivedAt),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -24,6 +24,16 @@ export default async function documentInboxRoutes(server: FastifyInstance, optio
|
||||
}
|
||||
return service.list(req.user.tenant_id!, limit, offset, query.search?.trim() || "")
|
||||
})
|
||||
server.get("/document-inbox/filing-options", async req => service.filingOptions(req.user.tenant_id!))
|
||||
server.post<{ Params: { id: string }; Body: { folder: string | null; type: string } }>("/document-inbox/:id/file", {
|
||||
schema: {
|
||||
params: { type: "object", required: ["id"], properties: { id: { type: "string", format: "uuid" } } },
|
||||
body: { type: "object", required: ["folder", "type"], additionalProperties: false, properties: {
|
||||
folder: { anyOf: [{ type: "string", format: "uuid" }, { type: "null" }] },
|
||||
type: { type: "string", format: "uuid" },
|
||||
} },
|
||||
},
|
||||
}, async req => service.fileDocument(req.user.tenant_id!, req.user.user_id, req.params.id, req.body.folder, req.body.type))
|
||||
server.get("/document-inbox/scanner", async req => ({
|
||||
connection: await service.connection(req.user.tenant_id!), path: SCANNER_DAV_PATH, maxFileSize: SCANNER_UPLOAD_LIMIT,
|
||||
}))
|
||||
|
||||
@@ -132,15 +132,20 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
await pool.query(`CREATE TABLE tenants (id bigint PRIMARY KEY, locked text);
|
||||
INSERT INTO tenants VALUES (1, null), (2, null);
|
||||
CREATE TABLE files (${columns}, CHECK (name <> 'reject.pdf'));
|
||||
CREATE TABLE filetags (id uuid PRIMARY KEY, tenant bigint, name text, archived boolean DEFAULT false);
|
||||
CREATE TABLE folders (id uuid PRIMARY KEY, tenant bigint, name text, parent uuid, "standardFiletype" uuid,
|
||||
"standardFiletypeIsOptional" boolean DEFAULT true, archived boolean DEFAULT false);
|
||||
CREATE TABLE auth_profiles (tenant_id bigint, user_id uuid, customer_for_portal bigint);`)
|
||||
const migrationRoot = path.resolve(__dirname, "../db/migrations")
|
||||
const journal = JSON.parse(await readFile(path.join(migrationRoot, "meta/_journal.json"), "utf8"))
|
||||
const entry = journal.entries.find((item: any) => item.tag === "0073_scanner_document_inbox")
|
||||
assert.ok(entry)
|
||||
assert.ok(journal.entries.filter((item: any) => item.idx < entry.idx).every((item: any) => item.when < entry.when))
|
||||
const entries = ["0073_scanner_document_inbox", "0074_scanner_inbox_filing"].map(tag => journal.entries.find((item: any) => item.tag === tag))
|
||||
await mkdir(path.join(tempMigrations, "meta"))
|
||||
await writeFile(path.join(tempMigrations, "meta/_journal.json"), JSON.stringify({ ...journal, entries: [entry] }))
|
||||
await writeFile(path.join(tempMigrations, entry.tag + ".sql"), await readFile(path.join(migrationRoot, entry.tag + ".sql")))
|
||||
for (const entry of entries) {
|
||||
assert.ok(entry)
|
||||
assert.ok(journal.entries.filter((item: any) => item.idx < entry.idx).every((item: any) => item.when < entry.when))
|
||||
await writeFile(path.join(tempMigrations, entry.tag + ".sql"), await readFile(path.join(migrationRoot, entry.tag + ".sql")))
|
||||
}
|
||||
await writeFile(path.join(tempMigrations, "meta/_journal.json"), JSON.stringify({ ...journal, entries }))
|
||||
const db = drizzle(pool) as any
|
||||
await migrate(db, { migrationsFolder: tempMigrations })
|
||||
await migrate(db, { migrationsFolder: tempMigrations })
|
||||
@@ -219,6 +224,83 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
assert.equal((await pool.query("SELECT count(*) FROM files WHERE name IN ('failed.pdf', 'reject.pdf')")).rows[0].count, "0")
|
||||
assert.equal((await send("failed.pdf")).statusCode, 201)
|
||||
})
|
||||
const targetFolder = randomUUID()
|
||||
const targetType = randomUUID()
|
||||
const alternativeType = randomUUID()
|
||||
const foreignFolder = randomUUID()
|
||||
const foreignType = randomUUID()
|
||||
const archivedFolder = randomUUID()
|
||||
const archivedType = randomUUID()
|
||||
await pool.query(`INSERT INTO filetags (id, tenant, name, archived) VALUES
|
||||
($1, 1, 'Rechnung', false), ($2, 1, 'Sonstiges', false), ($3, 2, 'Fremder Typ', false), ($4, 1, 'Archiviert', true)`,
|
||||
[targetType, alternativeType, foreignType, archivedType])
|
||||
await pool.query(`INSERT INTO folders (id, tenant, name, "standardFiletype", "standardFiletypeIsOptional", archived) VALUES
|
||||
($1, 1, 'Rechnungen', $2, false, false), ($3, 2, 'Fremder Ordner', null, true, false), ($4, 1, 'Archiviert', null, true, true)`,
|
||||
[targetFolder, targetType, foreignFolder, archivedFolder])
|
||||
const fileInto = (id: string, folder: string | null = targetFolder, type: string = targetType, headers = {}) =>
|
||||
app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload: { folder, type }, headers })
|
||||
await t.test("filing options and destinations exclude other tenants and archived entries", async () => {
|
||||
const options = await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })
|
||||
assert.equal(options.statusCode, 200)
|
||||
assert.deepEqual(options.json().folders.map((item: any) => item.id), [targetFolder])
|
||||
assert.equal(options.json().folders[0].standardFiletypeIsOptional, false)
|
||||
assert.deepEqual(options.json().types.map((item: any) => item.id).sort(), [targetType, alternativeType].sort())
|
||||
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||
for (const [folder, type] of [[foreignFolder, targetType], [archivedFolder, targetType], [targetFolder, foreignType],
|
||||
[null, archivedType], [targetFolder, alternativeType], [randomUUID(), targetType], [null, randomUUID()]]) {
|
||||
assert.equal((await fileInto(id, folder, type!)).statusCode, 400)
|
||||
}
|
||||
assert.equal((await fileInto(id, null, targetType, { "x-test-tenant": "2" })).statusCode, 404)
|
||||
assert.equal((await fileInto(randomUUID())).statusCode, 404)
|
||||
for (const payload of [{ folder: null }, { type: targetType }, { folder: "invalid", type: targetType }, { folder: null, type: null }]) {
|
||||
assert.equal((await app.inject({ method: "POST", url: `/api/document-inbox/${id}/file`, payload })).statusCode, 400)
|
||||
}
|
||||
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||
assert.equal((await fileInto(id)).statusCode, 423)
|
||||
await pool.query("UPDATE tenants SET locked=null WHERE id=1")
|
||||
await pool.query("INSERT INTO auth_profiles VALUES (1, $1, 123)", [userId])
|
||||
assert.equal((await fileInto(id)).statusCode, 403)
|
||||
assert.equal((await app.inject({ method: "GET", url: "/api/document-inbox/filing-options" })).statusCode, 403)
|
||||
await pool.query("DELETE FROM auth_profiles")
|
||||
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 1)
|
||||
const row = (await pool.query("SELECT folder, type FROM files WHERE id=$1", [id])).rows[0]
|
||||
assert.deepEqual(row, { folder: null, type: null })
|
||||
})
|
||||
await t.test("filing updates the original file once, keeps its content and cannot be undone by scanner retries", async () => {
|
||||
const id = (await service.list(1, 50, 0, "scan.pdf")).documents[0].id
|
||||
const before = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
|
||||
const countBefore = stored.size
|
||||
const responses = await Promise.all([fileInto(id, targetFolder, targetType, { "x-test-admin": "false" }), fileInto(id)])
|
||||
assert.deepEqual(responses.map(response => response.statusCode).sort(), [200, 409])
|
||||
const row = (await pool.query("SELECT * FROM files WHERE id=$1", [id])).rows[0]
|
||||
assert.equal(row.folder, targetFolder)
|
||||
assert.equal(row.type, targetType)
|
||||
assert.equal(row.updated_by, userId)
|
||||
assert.ok(row.updated_at)
|
||||
assert.equal(row.path, before.path)
|
||||
assert.equal(row.name, before.name)
|
||||
assert.deepEqual(stored.get(row.path), pdf)
|
||||
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 0)
|
||||
assert.equal((await send("scan.pdf")).statusCode, 204)
|
||||
assert.equal((await service.list(1, 50, 0, "scan.pdf")).total, 0)
|
||||
assert.equal((await service.list(2, 50, 0, "scan.pdf")).total, 1)
|
||||
assert.equal(stored.size, countBefore)
|
||||
assert.equal((await app.inject({ method: "HEAD", url: "/scanner-inbox/scan.pdf", headers: basic(first.username, first.password) })).statusCode, 200)
|
||||
const rootId = (await service.list(1, 50, 0, "parallel.pdf")).documents[0].id
|
||||
assert.equal((await fileInto(rootId, null, alternativeType)).statusCode, 200)
|
||||
assert.deepEqual((await pool.query("SELECT folder, type FROM files WHERE id=$1", [rootId])).rows[0], { folder: null, type: alternativeType })
|
||||
})
|
||||
await t.test("a failure marking the receipt rolls back file classification", async () => {
|
||||
const id = (await service.list(1, 50, 0, "failed.pdf")).documents[0].id
|
||||
await pool.query("ALTER TABLE scanner_inbox_documents ADD CONSTRAINT simulate_filing_failure CHECK (remote_filename <> 'failed.pdf' OR filed_at IS NULL)")
|
||||
assert.equal((await fileInto(id)).statusCode, 500)
|
||||
assert.deepEqual((await pool.query("SELECT folder, type FROM files WHERE id=$1", [id])).rows[0], { folder: null, type: null })
|
||||
assert.equal((await service.list(1, 50, 0, "failed.pdf")).total, 1)
|
||||
await pool.query("ALTER TABLE scanner_inbox_documents DROP CONSTRAINT simulate_filing_failure")
|
||||
await pool.query("UPDATE files SET archived=true WHERE id=$1", [id])
|
||||
assert.equal((await fileInto(id)).statusCode, 404)
|
||||
await pool.query("UPDATE files SET archived=false WHERE id=$1", [id])
|
||||
})
|
||||
await t.test("maintenance, portal access and administrator-only settings are enforced", async () => {
|
||||
await pool.query("UPDATE tenants SET locked='maintenance_tenant' WHERE id=1")
|
||||
assert.equal((await send("maintenance.pdf")).statusCode, 423)
|
||||
@@ -243,6 +325,6 @@ test("scanner inbox integration: migration, persistence, isolation, retries and
|
||||
assert.equal((await send("old.pdf")).statusCode, 401)
|
||||
await assert.rejects(service.accept(oldIdentity!, "late.pdf", pdf), { statusCode: 401 })
|
||||
assert.equal((await send("new.pdf", pdf, basic(first.username, rotated.json().connection.password))).statusCode, 201)
|
||||
assert.ok((await service.list(1, 50, 0, "")).total >= 4)
|
||||
assert.equal((await service.list(1, 50, 0, "")).total, 2)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user