feat(accounting): PDF/image preview in triage, opened at the QR-bill (no OCR)

Instead of OCR, let the admin read the payment slip directly: the triage modal
now embeds the captured document and, for PDFs, opens at the LAST page scrolled
to the Swiss QR-bill area so IBAN/amount/reference are visible while typing.

- backend: capture PDF page count at upload via pdf-lib (new
  inbound_documents.page_count, added to in-flight migration 124); new
  GET /api/admin/expenses/inbound/:id/file streams the stored file inline
  (safePath-guarded, nosniff). Raw-serve is acceptable here (admin views own
  uploads); the hardened rasterise-in-isolated-worker path stays a follow-up.
- frontend: getInboundFileBlob fetches the file with Bearer auth as a blob;
  the triage modal renders it (iframe for PDF with #page=<last>&view=FitH,300,
  <img> for camera photos) in a two-column layout next to the form.
- i18n: accounting.inbox.previewLoading / qrHint (EN + DE).

Verified: node -c, require-graph, migration-124 harness (page_count), npm run
build green.
This commit is contained in:
Luca
2026-06-11 00:38:37 +02:00
parent 2b5efebaff
commit 502fbad5a8
7 changed files with 101 additions and 7 deletions
+21
View File
@@ -14,6 +14,8 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { getStoragePath } = require('../config/storage');
const { createReadStream } = require('fs');
const { assertPathInside } = require('../utils/safePath');
const { db } = require('../database/db');
const expenseService = require('../services/expenseService');
const expenseCategoriesService = require('../services/expenseCategoriesService');
@@ -116,6 +118,25 @@ router.get('/inbound/:id', requirePermission('accounting.view'),
return successResponse(res, { document: await expenseService.getInbound(parseInt(req.params.id, 10)) });
}));
// Stream the stored file for in-browser preview (PDF / image). Admin-only,
// path-containment guarded. NOTE: this serves the raw file inline — the
// locked design's hardened path (rasterise in a network-isolated worker, never
// serve raw) is a follow-up; acceptable here as the admin views their own
// uploaded documents.
router.get('/inbound/:id/file', requirePermission('accounting.view'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const row = await db('inbound_documents').where({ id: parseInt(req.params.id, 10) })
.first('file_path', 'mime_type');
if (!row || !row.file_path) return res.status(404).json({ error: 'File not found', code: 'NO_FILE' });
const safe = assertPathInside(row.file_path, [path.join(getStoragePath(), 'business-docs')]);
res.setHeader('Content-Type', row.mime_type || 'application/octet-stream');
res.setHeader('Content-Disposition', 'inline');
res.setHeader('X-Content-Type-Options', 'nosniff');
createReadStream(safe).pipe(res);
}));
router.patch('/inbound/:id', requirePermission('accounting.manage'),
[param('id').isInt({ min: 1 })],
handleAsync(async (req, res) => {
+24 -4
View File
@@ -15,6 +15,7 @@
*/
const crypto = require('crypto');
const fsp = require('fs').promises;
const { PDFDocument } = require('pdf-lib');
const { db, logActivity } = require('../database/db');
const { AppError } = require('../utils/errors');
const { hasColumnCached } = require('../utils/schemaCache');
@@ -57,6 +58,7 @@ function transformInbound(row) {
parseStatus: row.parse_status,
parseMethod: row.parse_method,
parseError: row.parse_error,
pageCount: row.page_count,
supplierName: row.supplier_name,
invoiceNumber: row.invoice_number,
invoiceDate: toIsoDate(row.invoice_date),
@@ -119,9 +121,21 @@ function clampPage(page, pageSize) {
return { p, ps };
}
async function sha256OfFile(filePath) {
// Read the file once: SHA-256 (for dedup) + PDF page count (for the
// "jump to last page / QR" preview).
async function inspectFile(filePath, mimeType) {
const buf = await fsp.readFile(filePath);
return crypto.createHash('sha256').update(buf).digest('hex');
const sha = crypto.createHash('sha256').update(buf).digest('hex');
let pageCount = null;
if ((mimeType || '').includes('pdf')) {
try {
const pdf = await PDFDocument.load(buf, { updateMetadata: false });
pageCount = pdf.getPageCount();
} catch (e) {
logger.warn?.(`expenseService: could not read PDF page count for ${filePath}: ${e.message}`);
}
}
return { sha, pageCount };
}
// ── Inbound documents ──────────────────────────────────────────────────────
@@ -132,8 +146,13 @@ async function sha256OfFile(filePath) {
*/
async function recordInboundDocument({ source, filePath, originalFilename, mimeType }, adminId) {
let fileSha256 = null;
try { fileSha256 = await sha256OfFile(filePath); } catch (e) {
logger.warn?.(`expenseService: could not hash ${filePath}: ${e.message}`);
let pageCount = null;
try {
const info = await inspectFile(filePath, mimeType);
fileSha256 = info.sha;
pageCount = info.pageCount;
} catch (e) {
logger.warn?.(`expenseService: could not inspect ${filePath}: ${e.message}`);
}
let duplicateOfId = null;
@@ -164,6 +183,7 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
parse_status: parse.error ? 'failed' : (parse.parsed ? 'parsed' : 'pending'),
parse_method: parse.method || 'none',
parse_error: parse.error || null,
page_count: pageCount,
supplier_name: f.supplierName || null,
invoice_number: f.invoiceNumber || null,
invoice_date: f.invoiceDate || null,