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,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getFieldBundle } from "@/server/services/field/queries";
|
||||
import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context";
|
||||
|
||||
/** GET /api/v1/field/bundle?since=<ISO> — offline pull of the orders in scope (ARCHITEKTUR §4.6). */
|
||||
export async function GET(req: Request) {
|
||||
return withApi(req, async () => {
|
||||
const ctx = await requireApiContext("field", "field:execute");
|
||||
const raw = new URL(req.url).searchParams.get("since");
|
||||
const since = raw ? new Date(raw) : null;
|
||||
if (since && Number.isNaN(since.getTime())) return apiError("invalid", 400, "invalid since");
|
||||
return NextResponse.json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { openFieldDocument } from "@/server/services/field/documents";
|
||||
import { requireApiContext, withApi } from "@/server/services/sync/api-context";
|
||||
|
||||
/**
|
||||
* GET /api/v1/field/documents/<id>[?variant=preview] — authorised document delivery for the mobile
|
||||
* app (visibility + scope checked in the service). Only magic-byte-verified media types are served
|
||||
* inline; everything else is a download.
|
||||
*/
|
||||
const INLINE = /^(image\/(jpeg|png|webp)|application\/pdf|audio\/(webm|ogg|mp4|mpeg|wav))$/;
|
||||
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
return withApi(req, async () => {
|
||||
const ctx = await requireApiContext("field");
|
||||
const { id } = await params;
|
||||
const variant = new URL(req.url).searchParams.get("variant") === "preview" ? "preview" : "original";
|
||||
const { content, mimeType, fileName } = await openFieldDocument(ctx, id, variant);
|
||||
const safeName = fileName.replace(/["\\\r\n]/g, "_");
|
||||
const headers = new Headers({
|
||||
"Content-Type": mimeType,
|
||||
"Content-Disposition": `${INLINE.test(mimeType) ? "inline" : "attachment"}; filename="${safeName}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, max-age=300",
|
||||
});
|
||||
if (content.size != null) headers.set("Content-Length", String(content.size));
|
||||
return new Response(content.stream, { headers });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { syncRequestSchema } from "@/lib/sync/envelope";
|
||||
import { applyOperations } from "@/server/services/sync/apply";
|
||||
import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context";
|
||||
|
||||
/** POST /api/v1/sync — batch of offline/online operations (ARCHITEKTUR §4.6). */
|
||||
export async function POST(req: Request) {
|
||||
return withApi(req, async () => {
|
||||
const ctx = await requireApiContext("field");
|
||||
const body = syncRequestSchema.safeParse(await req.json().catch(() => null));
|
||||
if (!body.success) return apiError("invalid", 400, "invalid sync request", body.error.issues.slice(0, 10));
|
||||
return NextResponse.json(await applyOperations(ctx, body.data), { headers: { "Cache-Control": "no-store" } });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { storeFieldUpload, uploadMetaSchema } from "@/server/services/field/uploads";
|
||||
import { apiError, requireApiContext, withApi } from "@/server/services/sync/api-context";
|
||||
|
||||
/**
|
||||
* POST /api/v1/uploads — multipart: file, clientId (uuid), workOrderId, kind (photo|voice_note),
|
||||
* optional preview (thumbnail). Returns { documentId }; the same clientId returns the same document.
|
||||
*/
|
||||
const MAX_BYTES = 25 * 1024 * 1024;
|
||||
|
||||
export async function POST(req: Request) {
|
||||
return withApi(req, async () => {
|
||||
const ctx = await requireApiContext("field", "field:execute");
|
||||
const declared = Number(req.headers.get("content-length") ?? "0");
|
||||
if (declared > MAX_BYTES + 3 * 1024 * 1024) return apiError("invalid", 413, "file too large");
|
||||
|
||||
const form = await req.formData().catch(() => null);
|
||||
if (!form) return apiError("invalid", 400, "multipart body expected");
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) return apiError("invalid", 400, "file missing");
|
||||
if (file.size > MAX_BYTES) return apiError("invalid", 413, "file too large");
|
||||
const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") });
|
||||
if (!meta.success) return apiError("invalid", 400, "invalid upload metadata");
|
||||
const preview = form.get("preview");
|
||||
|
||||
const result = await storeFieldUpload(
|
||||
ctx,
|
||||
meta.data,
|
||||
{ bytes: Buffer.from(await file.arrayBuffer()), name: file.name, type: file.type },
|
||||
preview instanceof File && preview.size > 0 ? { bytes: Buffer.from(await preview.arrayBuffer()), name: preview.name, type: preview.type } : null,
|
||||
);
|
||||
return NextResponse.json(result, { status: result.duplicate ? 200 : 201, headers: { "Cache-Control": "no-store" } });
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user