L3 Auftragsimport: Prüfmaske, Upload, API und Lane-Bericht

- /imports: Upload per Drag & Drop/Dateiauswahl mit Fortschritt, Liste mit Status, Neu verarbeiten
- /imports/[id]: Originaldokument + Prüfmaske (Kunde, Objekt, Ansprechpartner, Auftrag, Positionen),
  unsichere Felder markiert, Kunden-/Objektentscheidung, Bestätigen/Verwerfen
- Datei-Route für die Vorschau, Server Actions (moduleGuard("imports"))
- API: POST /api/v1/work-orders/import, GET /api/v1/imports/[id], POST /api/v1/imports/[id]/confirm
- Texte messages/{de,en}/imports.json, Bericht docs/craftvia/lanes/import.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:21:15 +02:00
co-authored by Claude Opus 5
parent 12a764786a
commit cf012d40c2
16 changed files with 1612 additions and 3 deletions
@@ -0,0 +1,22 @@
import { confirmImport } from "@/server/services/imports/confirm";
import { apiError, importsApiContext } from "../../_context";
/**
* POST /api/v1/imports/[id]/confirm — JSON body = review form (src/lib/imports/review.ts
* `reviewFormSchema`). Creates/assigns customer, site, contact and the work order. Lane L3.
*/
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const ctx = await importsApiContext("import:write", "work_order:write");
const { id } = await params;
let body: unknown;
try {
body = await req.json();
} catch {
return Response.json({ error: "invalid", message: "json_required" }, { status: 400 });
}
return Response.json(await confirmImport(ctx, id, body));
} catch (err) {
return apiError(err);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { getImportDetail } from "@/server/services/imports/queries";
import { apiError, importsApiContext } from "../_context";
/** GET /api/v1/imports/[id] — import status, extraction (with confidences), candidates. Lane L3. */
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const ctx = await importsApiContext("import:write");
const { id } = await params;
return Response.json(await getImportDetail(ctx, id));
} catch (err) {
return apiError(err);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { moduleGuard } from "@/server/action-guard";
import { ForbiddenError, type Permission } from "@/server/rbac";
import { ModuleDisabledError } from "@/server/modules";
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* Lane L3 helper for the /api/v1 import handlers (thin adapters). Uses the same DB-authoritative
* guard as the server actions (session → account/identity status → permissions → module).
* Not a route: files starting with "_" are ignored by the App Router.
* TODO(architecture): replace with a shared `requireApiContext` once it exists.
*/
const guard = moduleGuard("imports");
export async function importsApiContext(...permissions: Permission[]): Promise<ServiceCtx> {
return ctxFromGuard(await guard(...permissions));
}
const STATUS: Record<ServiceError["code"], number> = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 409 };
/** Map service/guard errors to JSON responses without leaking internals. */
export function apiError(err: unknown): Response {
if (err instanceof ServiceError) {
return Response.json({ error: err.code, message: err.message, details: err.code === "invalid" ? err.details : undefined }, { status: STATUS[err.code] });
}
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
if (err instanceof Error && /Nicht angemeldet|nicht mehr gueltig/.test(err.message)) {
return Response.json({ error: "unauthorized" }, { status: 401 });
}
if (err instanceof Error && /Konto ist nicht aktiv|Passwortwechsel/.test(err.message)) {
return Response.json({ error: "forbidden" }, { status: 403 });
}
console.error("[api/imports]", err);
return Response.json({ error: "internal" }, { status: 500 });
}
@@ -0,0 +1,30 @@
import { createImport } from "@/server/services/imports/upload";
import { apiError, importsApiContext } from "../../imports/_context";
/**
* POST /api/v1/work-orders/import — multipart upload of an order document (field `file`).
* Lane L3 (import). Response 201 `{ id, status }`; the extraction runs in the background.
* Note: bodies > 10 MB need `experimental.proxyClientMaxBodySize` in next.config.ts (see lane report).
*/
export async function POST(req: Request) {
try {
const ctx = await importsApiContext("import:write");
let form: FormData;
try {
form = await req.formData();
} catch {
return Response.json({ error: "invalid", message: "multipart_required" }, { status: 400 });
}
const file = form.get("file");
if (!(file instanceof File)) return Response.json({ error: "invalid", message: "file_missing" }, { status: 400 });
const job = await createImport(ctx, {
bytes: Buffer.from(await file.arrayBuffer()),
fileName: file.name,
mimeType: file.type,
});
const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } });
return Response.json(current ?? { id: job.id, status: job.status }, { status: 201 });
} catch (err) {
return apiError(err);
}
}