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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user