Merge lane/einsatz in feature/craftvia-mvp

Konflikte gelöst: processors/index.ts (report-pdf + image-derivatives),
(app)/layout.tsx (Logo, Glocke, AccountInactiveNotice). L5-Mobilseiten
report/sign nach src/app/(field)/m/(core)/orders/[id]/ verschoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:32:35 +02:00
co-authored by Claude Opus 5
76 changed files with 5608 additions and 66 deletions
@@ -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 },
});
}
+1 -1
View File
@@ -11,7 +11,7 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
"import-extraction": () => import("./import-extraction").then((m) => m.process),
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
// 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). */