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:
@@ -0,0 +1,48 @@
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import { storage } from "@/server/storage/adapter";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import type { JobPayload } from "../queues";
|
||||
|
||||
/**
|
||||
* Queue "image-derivatives" (lane L4): creates the 400 px JPEG thumbnail (Document.previewKey)
|
||||
* for photos whose client did not send one. EXIF orientation is applied (sharp.rotate()).
|
||||
* Idempotent: documents that already have a preview are skipped.
|
||||
*/
|
||||
|
||||
export const THUMBNAIL_SIZE = 400;
|
||||
|
||||
async function streamToBuffer(stream: ReadableStream<Uint8Array>): Promise<Buffer> {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = stream.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
|
||||
export async function createThumbnail(bytes: Buffer): Promise<Buffer> {
|
||||
const sharp = (await import("sharp")).default;
|
||||
return sharp(bytes).rotate().resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, { fit: "inside", withoutEnlargement: true }).jpeg({ quality: 75 }).toBuffer();
|
||||
}
|
||||
|
||||
export async function process(payload: JobPayload): Promise<void> {
|
||||
const db = dbForTenant(payload.tenantId);
|
||||
const doc = await db.document.findFirst({ where: { id: payload.entityId, category: "photo", deletedAt: null } });
|
||||
if (!doc || doc.previewKey) return;
|
||||
const content = await storage.get(doc.storageKey);
|
||||
if (!content) return; // stub storage or object missing — nothing to derive
|
||||
const thumb = await createThumbnail(await streamToBuffer(content.stream));
|
||||
const stored = await storage.put({ tenantId: payload.tenantId, filename: `thumb-${doc.fileName.replace(/\.[^.]+$/, "")}.jpg`, contentType: "image/jpeg", bytes: thumb });
|
||||
await db.document.update({ where: { id: doc.id }, data: { previewKey: stored.storageKey } });
|
||||
await writeAuditLog({
|
||||
tenantId: payload.tenantId,
|
||||
actorId: payload.actorId ?? undefined,
|
||||
action: "update",
|
||||
entity: "document",
|
||||
entityId: doc.id,
|
||||
before: { previewKey: null },
|
||||
after: { previewKey: stored.storageKey },
|
||||
});
|
||||
}
|
||||
@@ -11,7 +11,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
|
||||
// lane-imports: "import-extraction": () => import("./import-extraction").then((m) => m.process),
|
||||
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
||||
// lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process),
|
||||
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||
"image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||
};
|
||||
|
||||
/** Inline fallback when no Redis is available (dev/demo). */
|
||||
|
||||
Reference in New Issue
Block a user