- 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>
104 lines
4.4 KiB
TypeScript
104 lines
4.4 KiB
TypeScript
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:<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}`);
|
|
|
|
// 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 };
|
|
}
|