Integration: Stubs von L2/L3/L4/L5 gegen echte Services getauscht

- 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>
This commit is contained in:
2026-09-14 12:49:19 +02:00
co-authored by Claude Opus 5
parent 2984cc18d3
commit 3883296300
37 changed files with 216 additions and 798 deletions
+45
View File
@@ -0,0 +1,45 @@
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 };
}