L2 Aufträge & Backoffice: Server Actions und /api/v1/work-orders

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:29:42 +02:00
co-authored by Claude Opus 5
parent 2311b35d8c
commit a962fa8be9
10 changed files with 755 additions and 0 deletions
@@ -0,0 +1,22 @@
import { NextResponse, type NextRequest } from "next/server";
import { assignWorkOrder } from "@/server/services/work-orders/assign";
import { apiContext, apiError, optionalVersion, readJson } from "../../_http";
/** POST /api/v1/work-orders/[id]/assign — body { teamId, userIds?, teamLeadUserId?, baseVersion? }. */
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const ctx = await apiContext("work_order:assign");
const body = await readJson(req);
const res = await assignWorkOrder(ctx, {
workOrderId: id,
teamId: String(body.teamId ?? ""),
userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [],
teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null,
baseVersion: optionalVersion(body.baseVersion),
});
return NextResponse.json(res);
} catch (err) {
return apiError(err);
}
}
@@ -0,0 +1,40 @@
import { NextResponse, type NextRequest } from "next/server";
import type { DocumentCategory, DocumentVisibility } from "@prisma/client";
import { ServiceError } from "@/server/services/context";
import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents";
import { apiContext, apiError } from "../../_http";
/**
* POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?).
* Used by the backoffice form (HTML post → 303 back to the documents tab) and by API clients (JSON).
* A route handler instead of a server action avoids the 1 MB server-action body limit.
*/
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html");
try {
// CSRF defence for the cookie-authenticated form post: same-origin only.
const origin = req.headers.get("origin");
if (origin && origin !== req.nextUrl.origin) throw new ServiceError("forbidden", "cross_origin");
const ctx = await apiContext("document:write");
const form = await req.formData();
const file = form.get("file");
if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing");
const doc = await uploadWorkOrderDocument(ctx, {
workOrderId: id,
bytes: new Uint8Array(await file.arrayBuffer()),
fileName: file.name,
declaredMime: file.type,
category: String(form.get("category") ?? "other") as DocumentCategory,
visibility: String(form.get("visibility") ?? "team") as DocumentVisibility,
title: typeof form.get("title") === "string" && String(form.get("title")).trim() ? String(form.get("title")).trim() : null,
});
if (wantsHtml) return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, req.nextUrl.origin), 303);
return NextResponse.json(doc, { status: 201 });
} catch (err) {
if (wantsHtml && err instanceof ServiceError) {
return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, req.nextUrl.origin), 303);
}
return apiError(err);
}
}
@@ -0,0 +1,29 @@
import { NextResponse, type NextRequest } from "next/server";
import type { MaterialPlanInput } from "@/lib/work-orders/schemas";
import { addMaterialPlan, getMaterialOverview } from "@/server/services/work-orders/materials";
import { apiContext, apiError, readJson } from "../../_http";
type Params = { params: Promise<{ id: string }> };
/** GET /api/v1/work-orders/[id]/materials — planned vs. actual incl. deviations. */
export async function GET(_req: NextRequest, { params }: Params) {
try {
const { id } = await params;
const ctx = await apiContext();
return NextResponse.json({ items: await getMaterialOverview(ctx, id) });
} catch (err) {
return apiError(err);
}
}
/** POST /api/v1/work-orders/[id]/materials — add a material plan item { name, articleNumber?, plannedQuantity, unit, notes? }. */
export async function POST(req: NextRequest, { params }: Params) {
try {
const { id } = await params;
const ctx = await apiContext("work_order:write");
const plan = await addMaterialPlan(ctx, id, (await readJson(req)) as MaterialPlanInput);
return NextResponse.json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 });
} catch (err) {
return apiError(err);
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse, type NextRequest } from "next/server";
import type { UpdateWorkOrderInput } from "@/lib/work-orders/schemas";
import { computeCompletionBlockers } from "@/server/services/work-orders/completion";
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
import { updateWorkOrder } from "@/server/services/work-orders/update";
import { apiContext, apiError, optionalVersion, readJson } from "../_http";
type Params = { params: Promise<{ id: string }> };
/** GET /api/v1/work-orders/[id] — detail incl. transitions available to the caller and completion blockers. */
export async function GET(_req: NextRequest, { params }: Params) {
try {
const { id } = await params;
const ctx = await apiContext();
const wo = await getWorkOrderDetail(ctx, id);
const [blockers] = await Promise.all([computeCompletionBlockers(ctx, id)]);
return NextResponse.json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers });
} catch (err) {
return apiError(err);
}
}
/** PATCH /api/v1/work-orders/[id] — body: partial master data + optional baseVersion (409 on mismatch). */
export async function PATCH(req: NextRequest, { params }: Params) {
try {
const { id } = await params;
const ctx = await apiContext();
const { baseVersion, ...patch } = await readJson(req);
const res = await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion));
return NextResponse.json(res);
} catch (err) {
return apiError(err);
}
}
@@ -0,0 +1,24 @@
import { NextResponse, type NextRequest } from "next/server";
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
import { apiContext, apiError, optionalVersion, readJson } from "../../_http";
/**
* POST /api/v1/work-orders/[id]/transition — body { to, reason?, baseVersion? }.
* 403 forbidden · 404 not in scope · 409 version conflict · 422 invalid / blocked (details: CompletionBlocker[]).
*/
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const { id } = await params;
const ctx = await apiContext();
const body = await readJson(req);
const res = await transitionWorkOrder(ctx, {
workOrderId: id,
to: String(body.to ?? "") as never,
reason: typeof body.reason === "string" ? body.reason : null,
baseVersion: optionalVersion(body.baseVersion),
});
return NextResponse.json(res);
} catch (err) {
return apiError(err);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextResponse } from "next/server";
import { moduleGuard } from "@/server/action-guard";
import { ModuleDisabledError } from "@/server/modules";
import { ForbiddenError, type Permission } from "@/server/rbac";
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* /api/v1/work-orders helpers (lane L2). Authentication/authorisation reuses the DB-authoritative
* moduleGuard (session cookie); the fundament's generic `requireApiContext` does not exist yet —
* see docs/craftvia/lanes/auftraege.md.
*/
export async function apiContext(...permissions: Permission[]): Promise<ServiceCtx> {
const g = await moduleGuard("work_orders")(...permissions);
return ctxFromGuard(g);
}
const STATUS: Record<ServiceError["code"], number> = {
not_found: 404,
forbidden: 403,
invalid: 422,
conflict: 409,
blocked: 422,
};
export function apiError(err: unknown): NextResponse {
if (err instanceof ServiceError) {
return NextResponse.json({ error: { code: err.code, message: err.message, details: err.details ?? null } }, { status: STATUS[err.code] });
}
if (err instanceof ForbiddenError) return NextResponse.json({ error: { code: "forbidden", message: "forbidden" } }, { status: 403 });
if (err instanceof ModuleDisabledError) return NextResponse.json({ error: { code: "forbidden", message: "module_disabled" } }, { status: 403 });
if (err instanceof SyntaxError) return NextResponse.json({ error: { code: "invalid", message: "invalid_json" } }, { status: 400 });
const msg = err instanceof Error ? err.message : "";
if (/Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(msg)) {
return NextResponse.json({ error: { code: "unauthorized", message: "unauthorized" } }, { status: 401 });
}
console.error("[api/v1/work-orders]", err);
return NextResponse.json({ error: { code: "internal", message: "internal" } }, { status: 500 });
}
export async function readJson(req: Request): Promise<Record<string, unknown>> {
const body = (await req.json()) as unknown;
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ServiceError("invalid", "body_must_be_object");
return body as Record<string, unknown>;
}
export function optionalVersion(v: unknown): number | undefined {
return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
}
+30
View File
@@ -0,0 +1,30 @@
import { NextResponse, type NextRequest } from "next/server";
import { parseListParams } from "@/lib/work-orders/filters";
import type { CreateWorkOrderInput } from "@/lib/work-orders/schemas";
import { createWorkOrder } from "@/server/services/work-orders/create";
import { listWorkOrders } from "@/server/services/work-orders/list";
import { apiContext, apiError, readJson } from "./_http";
/** GET /api/v1/work-orders — filters as in the backoffice list (§21), always within workOrderScope. */
export async function GET(req: NextRequest) {
try {
const ctx = await apiContext();
return NextResponse.json(await listWorkOrders(ctx, parseListParams(req.nextUrl.searchParams)));
} catch (err) {
return apiError(err);
}
}
/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). */
export async function POST(req: NextRequest) {
try {
const ctx = await apiContext();
const body = await readJson(req);
// Import linkage and number keys are reserved for the import/emergency services.
delete body.sourceImportId;
const created = await createWorkOrder(ctx, body as CreateWorkOrderInput);
return NextResponse.json(created, { status: 201 });
} catch (err) {
return apiError(err);
}
}