- services/field: Einsatz-Sessions (Anfahrt/Arbeit/Pause als TimeEntry-Segmente, eine aktive Session je User+Auftrag), Zeitkorrektur mit Recht + Grund + Audit, Checkliste, Material (Abweichung nur mit Begründung, Zusatzmaterial), Notizen, Fotos, Sprachnotizen (ohne Transkriptions-Processor Status disabled), Uploads (idempotent je Mandant), autorisierte Dokument-Auslieferung, Lesemodelle + Bundle - services/sync: applyOperations mit Idempotenz, baseVersion-Konfliktprüfung, Registry für Ops anderer Lanes, lane-lokaler requireApiContext - /api/v1/sync, /api/v1/uploads, /api/v1/field/bundle, /api/v1/field/documents/[id] - lib/sync/ops.ts (Zod-Payloads je opType), lib/field/material-rules.ts - Stubs mit Vertragssignatur: transitionWorkOrder (L2), storeFile (§4.3), getSiteHistory (L1) - Processor image-derivatives + Registrierung, Audit-Entity-Labels - Tests: test-einsatz-field (48 Prüfungen), test-einsatz-sync (38 Prüfungen) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
98 lines
4.6 KiB
TypeScript
98 lines
4.6 KiB
TypeScript
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";
|
|
|
|
/**
|
|
* STUB (lane L4) — stands in for `src/server/services/documents/store.ts#storeFile` (ARCHITEKTUR §4.3),
|
|
* which does not exist on the base commit. Same signature; validates allowlist, size limit per
|
|
* kind, magic bytes, normalises the file name, computes SHA-256 and stores via the storage adapter.
|
|
* On merge of the documents contract: delete this file and import the shared implementation.
|
|
*/
|
|
|
|
export type StoreFileInput = {
|
|
bytes: Buffer;
|
|
fileName: string;
|
|
declaredMime: string;
|
|
category: DocumentCategory;
|
|
visibility: DocumentVisibility;
|
|
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
|
|
lineageId?: string;
|
|
title?: string | null;
|
|
};
|
|
|
|
const MB = 1024 * 1024;
|
|
|
|
type Kind = "image" | "pdf" | "audio";
|
|
const LIMITS: Record<Kind, number> = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB };
|
|
|
|
/** Detects the real MIME type from magic bytes (null = not allowed). */
|
|
export function sniffMime(bytes: Buffer): { mime: string; kind: Kind } | null {
|
|
const b = bytes;
|
|
const at = (offset: number, sig: number[]) => sig.every((v, i) => b[offset + i] === v);
|
|
const ascii = (offset: number, s: string) => b.length >= offset + s.length && b.toString("latin1", offset, offset + s.length) === s;
|
|
if (b.length < 12) return null;
|
|
if (at(0, [0xff, 0xd8, 0xff])) return { mime: "image/jpeg", kind: "image" };
|
|
if (at(0, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return { mime: "image/png", kind: "image" };
|
|
if (ascii(0, "RIFF") && ascii(8, "WEBP")) return { mime: "image/webp", kind: "image" };
|
|
if (ascii(0, "%PDF-")) return { mime: "application/pdf", kind: "pdf" };
|
|
if (at(0, [0x1a, 0x45, 0xdf, 0xa3])) return { mime: "audio/webm", kind: "audio" };
|
|
if (ascii(0, "OggS")) return { mime: "audio/ogg", kind: "audio" };
|
|
if (ascii(0, "RIFF") && ascii(8, "WAVE")) return { mime: "audio/wav", kind: "audio" };
|
|
if (ascii(4, "ftyp")) {
|
|
const brand = b.toString("latin1", 8, 12);
|
|
if (/^(heic|heix|mif1|msf1)$/.test(brand)) return { mime: "image/heic", kind: "image" };
|
|
return { mime: "audio/mp4", kind: "audio" }; // M4A / MP4 audio from iOS MediaRecorder
|
|
}
|
|
if (ascii(0, "ID3") || (b[0] === 0xff && (b[1] & 0xe0) === 0xe0)) return { mime: "audio/mpeg", kind: "audio" };
|
|
return null;
|
|
}
|
|
|
|
export function normalizeFileName(name: string): string {
|
|
const base = name.split(/[\\/]/).pop() ?? "datei";
|
|
return base.normalize("NFC").replace(/[\u0000-\u001f<>:"|?*]+/g, "_").trim().slice(0, 180) || "datei";
|
|
}
|
|
|
|
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
|
|
const sniffed = sniffMime(input.bytes);
|
|
if (!sniffed) throw new ServiceError("invalid", "file type not allowed");
|
|
if (input.bytes.byteLength > LIMITS[sniffed.kind]) throw new ServiceError("invalid", "file too large");
|
|
const declaredBase = input.declaredMime.split(";")[0].trim().toLowerCase();
|
|
// declared type must at least belong to the same family (image/*, audio/*, video/webm|mp4 for audio containers, pdf)
|
|
const family = declaredBase.split("/")[0];
|
|
const familyOk =
|
|
sniffed.kind === "pdf" ? declaredBase === "application/pdf" : sniffed.kind === "image" ? family === "image" : family === "audio" || declaredBase === "video/webm" || declaredBase === "video/mp4";
|
|
if (!familyOk) throw new ServiceError("invalid", "declared MIME type does not match content");
|
|
|
|
const fileName = normalizeFileName(input.fileName);
|
|
const checksum = createHash("sha256").update(input.bytes).digest("hex");
|
|
const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: sniffed.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 } });
|
|
if (last) version = last.version + 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: stored.storageKey,
|
|
mimeType: sniffed.mime,
|
|
fileSize: input.bytes.byteLength,
|
|
checksum,
|
|
version,
|
|
lineageId,
|
|
visibility: input.visibility,
|
|
uploadStatus: "uploaded",
|
|
uploadedById: ctx.userId,
|
|
},
|
|
});
|
|
}
|