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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user