- Aufträge: createWorkOrder/transitionWorkOrder/getCompletionBlockers aus L2 - Dokumente: storeFile aus L1; neu services/documents/read.ts (readStoredBytes, readDocumentBytes mit Prüfsummenprüfung); L2-Upload nutzt zentrale Ablage - Dubletten aus L1 (findDuplicateCustomers(ctx)); Objekt-Kandidaten als imports/site-candidates.ts; Dateityp-Erkennung mobil als field/mime.ts - Objekt-Historie mobil als Adapter auf L1 getSiteHistory, PDF über /api/v1/reports/:id/pdf - L4-Upload-Idempotenz: fester Upload-Lineage nach storeFile, Race → Soft-Delete + Replay - PDF-Worker-Kontext: document:write zum Ablegen des Berichts-PDF - Import: doppeltes Work-Order-Audit entfernt; bestätigte Aufträge starten planned - Dateilinks in Auftragsdetail auf /files/[documentId] - proxy: /api/v1 ohne Session → 401 JSON statt Login-Redirect - ARCHITEKTUR §2: Objekt-Historie für Feldrollen (freigegebene Einsätze aller Teams) Gate: tsc, lint, build, 42/42 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
46 lines
2.1 KiB
TypeScript
46 lines
2.1 KiB
TypeScript
import { storage } from "@/server/storage/adapter";
|
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
|
import { sha256Hex } from "@/server/services/documents/store";
|
|
|
|
/**
|
|
* Server-side byte access to stored documents (workers, PDF rendering, import extraction).
|
|
*
|
|
* NO visibility/scope check here — this is not a download path. User-facing downloads must go
|
|
* through `services/documents/access.ts#openDocumentContent` (/files/[documentId]).
|
|
*/
|
|
|
|
/** Read raw bytes by storage key; null when the object does not exist (or storage is the stub). */
|
|
export async function readStoredBytes(storageKey: string): Promise<Buffer | null> {
|
|
const content = await storage.get(storageKey);
|
|
if (!content) return null;
|
|
const chunks: Uint8Array[] = [];
|
|
const reader = content.stream.getReader();
|
|
for (;;) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
if (value) chunks.push(value);
|
|
}
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
/**
|
|
* Load a tenant document's bytes and verify its SHA-256 checksum.
|
|
* Throws `not_found` (missing/soft-deleted/foreign tenant/no content) or `invalid` (checksum mismatch).
|
|
*/
|
|
export async function readDocumentBytes(
|
|
ctx: ServiceCtx,
|
|
documentId: string,
|
|
): Promise<{ bytes: Buffer; mimeType: string; fileName: string; checksum: string }> {
|
|
const document = await ctx.db.document.findFirst({
|
|
where: { id: documentId, deletedAt: null },
|
|
select: { storageKey: true, mimeType: true, fileName: true, checksum: true },
|
|
});
|
|
if (!document) throw new ServiceError("not_found", "document not found");
|
|
// defense in depth: the key must carry the tenant prefix
|
|
if (!document.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document content not available");
|
|
const bytes = await readStoredBytes(document.storageKey);
|
|
if (!bytes) throw new ServiceError("not_found", "document content not available");
|
|
if (sha256Hex(bytes) !== document.checksum) throw new ServiceError("invalid", "document checksum mismatch");
|
|
return { bytes, mimeType: document.mimeType, fileName: document.fileName, checksum: document.checksum };
|
|
}
|