L5 Berichte & Unterschrift: Berichtsinhalt, Services, Unterschrift und PDF

ReportContent-Vertrag, Content-Builder mit Tagesfilter, Services für Tages-/Abschlussbericht,
Bearbeiten, Absenden, Freigabe, Zurückweisen, neue Version, Unterschrift und PDF-Erzeugung
(playwright-core, Worker-Processor, Dockerfile-Stage worker). Stubs für L2-Transition/Blocker
und Dokumenten-Store.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:40 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 8a6fdd8f7a
26 changed files with 2129 additions and 3 deletions
@@ -0,0 +1,88 @@
/**
* STUB (lane L5 „Berichte") for the shared file contract ARCHITEKTUR §4.3
* `src/server/services/documents/store.ts#storeFile` (not yet provided by the architect / documents lane).
*
* Minimal implementation behind the contracted interface: allowlist + magic bytes + size limit,
* SHA-256, storage.put, Document row, lineage versioning. At merge the import in
* services/reports/*.ts is switched to the real store and this file is deleted.
*/
import { createHash, randomUUID } from "node:crypto";
import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client";
import { storage } from "@/server/storage/adapter";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
export type StoreFileInput = {
bytes: Uint8Array;
fileName: string;
declaredMime: string;
category: DocumentCategory;
visibility: DocumentVisibility;
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
lineageId?: string;
title?: string | null;
};
const LIMITS: Record<string, number> = { "application/pdf": 25 * 1024 * 1024, "image/png": 15 * 1024 * 1024, "image/jpeg": 15 * 1024 * 1024, "image/webp": 15 * 1024 * 1024 };
export function sniffMime(bytes: Uint8Array): string | null {
const b = bytes;
if (b.length >= 5 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) return "application/pdf";
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png";
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp";
return null;
}
export function sha256Hex(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
const mime = sniffMime(input.bytes);
if (!mime || mime !== input.declaredMime) throw new ServiceError("invalid", "file type not allowed or does not match content");
if (input.bytes.byteLength === 0 || input.bytes.byteLength > (LIMITS[mime] ?? 0)) throw new ServiceError("invalid", "file size not allowed");
const fileName = input.fileName.normalize("NFC").replace(/[^\w.\- ]+/g, "_").slice(0, 120) || "datei";
const checksum = sha256Hex(input.bytes);
const put = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: mime, bytes: input.bytes });
let version = 1;
const lineageId = input.lineageId ?? randomUUID();
if (input.lineageId) {
const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
version = (last?.version ?? 0) + 1;
}
return ctx.db.document.create({
data: {
tenantId: ctx.tenantId,
customerId: input.links.customerId ?? null,
siteId: input.links.siteId ?? null,
workOrderId: input.links.workOrderId ?? null,
category: input.category,
title: input.title ?? null,
fileName,
storageKey: put.storageKey,
mimeType: mime,
fileSize: input.bytes.byteLength,
checksum,
version,
lineageId,
visibility: input.visibility,
uploadStatus: "uploaded",
uploadedById: ctx.userId,
},
});
}
/** Read the bytes of a stored document (worker/PDF rendering). null if the backend has no bytes. */
export async function readFileBytes(storageKey: string): Promise<Uint8Array | null> {
const obj = await storage.get(storageKey);
if (!obj) return null;
const chunks: Uint8Array[] = [];
const reader = obj.stream.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
if (value) chunks.push(value);
}
return Buffer.concat(chunks);
}