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"; import { storeFile } from "@/server/services/documents/store"; import { sniffMime } from "./mime"; /** * 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::`, 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; 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}`); // The central store treats `lineageId` as "new version of an existing document", so the file is // stored as a fresh document and the deterministic upload lineage (idempotency key per device // clientId) is attached afterwards. A concurrent upload with the same clientId loses the unique // (lineageId, version) race: its document is soft-deleted and the winner is returned. const created = 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 }, }); let doc; try { doc = await ctx.db.document.update({ where: { id: created.id }, data: { lineageId } }); } catch (err) { if (isUniqueViolation(err)) { await ctx.db.document.update({ where: { id: created.id }, data: { deletedAt: new Date() } }); 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 }; }