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>
89 lines
3.8 KiB
TypeScript
89 lines
3.8 KiB
TypeScript
/**
|
|
* 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);
|
|
}
|