L4 Einsatz mobil: Field-Services, Sync-API, Uploads und Tests

- 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>
This commit is contained in:
2026-09-14 12:29:48 +02:00
co-authored by Claude Opus 5
parent bf4456718e
commit 952e74cddd
31 changed files with 2312 additions and 1 deletions
+98
View File
@@ -0,0 +1,98 @@
import { z } from "zod";
import { storage } from "@/server/storage/adapter";
import { dispatchJob } from "@/server/jobs/dispatch";
import { JOB_QUEUES } from "@/server/jobs/queues";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { audit, isUniqueViolation, requireFieldOrder } from "./common";
// TODO(merge documents contract §4.3): replace with "@/server/services/documents/store"
import { sniffMime, storeFile } from "./stubs/documents-store";
/**
* Binary uploads of the mobile app (ARCHITEKTUR §4.6: POST /api/v1/uploads → documentId).
* Idempotent per tenant over the device `clientId` (stored as the document lineage
* `upload:<tenantId>:<clientId>`, so the same clientId in another tenant never collides).
* The client sends a compressed image plus an optional 400 px thumbnail; without a thumbnail
* the image-derivatives job creates one.
*/
export const uploadMetaSchema = z.object({
clientId: z.string().uuid(),
workOrderId: z.string().min(1).max(64),
kind: z.enum(["photo", "voice_note"]),
});
export type UploadMeta = z.infer<typeof uploadMetaSchema>;
export type UploadFile = { bytes: Buffer; name: string; type: string };
const MAX_PREVIEW_BYTES = 2 * 1024 * 1024;
export function uploadLineageId(tenantId: string, clientId: string): string {
return `upload:${tenantId}:${clientId}`;
}
export async function storeFieldUpload(
ctx: ServiceCtx,
meta: UploadMeta,
file: UploadFile,
preview?: UploadFile | null,
): Promise<{ documentId: string; duplicate: boolean }> {
const wo = await requireFieldOrder(ctx, meta.workOrderId, { editable: true });
const lineageId = uploadLineageId(ctx.tenantId, meta.clientId);
const replay = async () => {
const existing = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "asc" }, select: { id: true, workOrderId: true, uploadedById: true } });
if (!existing) return null;
if (existing.workOrderId !== wo.id || existing.uploadedById !== ctx.userId) throw new ServiceError("invalid", "clientId already used");
return { documentId: existing.id, duplicate: true };
};
const prior = await replay();
if (prior) return prior;
const sniffed = sniffMime(file.bytes);
const expectedKind = meta.kind === "photo" ? "image" : "audio";
if (!sniffed || sniffed.kind !== expectedKind) throw new ServiceError("invalid", `file is not a valid ${expectedKind}`);
let doc;
try {
doc = await storeFile(ctx, {
bytes: file.bytes,
fileName: file.name || (meta.kind === "photo" ? "foto.jpg" : "sprachnotiz.webm"),
declaredMime: file.type || sniffed.mime,
category: meta.kind,
visibility: "team",
links: { workOrderId: wo.id, siteId: wo.siteId, customerId: wo.customerId },
lineageId,
});
} catch (err) {
if (isUniqueViolation(err)) {
const raced = await replay();
if (raced) return raced;
}
throw err;
}
if (meta.kind === "photo") {
const previewMime = preview ? sniffMime(preview.bytes) : null;
if (preview && previewMime?.kind === "image" && preview.bytes.byteLength <= MAX_PREVIEW_BYTES) {
const stored = await storage.put({ tenantId: ctx.tenantId, filename: `thumb-${doc.fileName}`, contentType: previewMime.mime, bytes: preview.bytes });
doc = await ctx.db.document.update({ where: { id: doc.id }, data: { previewKey: stored.storageKey } });
} else {
try {
await dispatchJob(JOB_QUEUES.imageDerivatives, { tenantId: ctx.tenantId, entityId: doc.id, actorId: ctx.userId });
} catch (err) {
// thumbnails are optional — the original stays usable
console.error("[field] image-derivatives dispatch failed:", (err as Error).message);
}
}
}
await audit(ctx, "create", "document", doc.id, null, {
workOrderId: wo.id,
category: doc.category,
fileName: doc.fileName,
mimeType: doc.mimeType,
fileSize: doc.fileSize,
checksum: doc.checksum,
});
return { documentId: doc.id, duplicate: false };
}