L10b Betrieb & Aufräumen: /api/v1 über gemeinsamen Adapter, einheitliches Fehlerformat, Rate Limiting
Aufräumpunkt a: Die lane-lokalen API-Kontexte (imports/_context.ts, sync/api-context.ts,
reports/http.ts, work-orders/_http.ts mit moduleGuard) sind entfernt. Alle v1-Routen laufen über
requireApiContext (DB-autoritative Rechte, 401/403) und withApi/toErrorResponse (respond.ts):
- Fehlerformat überall { error: { code, message, details? } }; invalid und blocked → 422,
conflict → 409, payload_too_large → 413, rate_limited → 429 + Retry-After.
- Same-Origin-Prüfung in withApi für jede Mutation vor der Anmeldung (vorher fehlte sie bei
imports, reports und work-orders).
- Rate Limiting je Nutzer mit rate-limit.ts: api (API_RATE_LIMIT_PER_MINUTE, 300/min) und
apiField für sync/uploads/field (API_FIELD_RATE_LIMIT_PER_MINUTE, 1200/min).
- Clients angepasst: Import-Uploader liest das neue Fehlerformat, Upload/Outbox werten 422 als
endgültig ungültig (429 bleibt transient mit Backoff).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,14 +1,12 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { ApiError, json, withApi } from "@/server/api/respond";
|
||||
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" } });
|
||||
});
|
||||
}
|
||||
export const GET = withApi(async (req: Request) => {
|
||||
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())) throw new ApiError("invalid", "invalid since");
|
||||
return json(await getFieldBundle(ctx, since), { headers: { "Cache-Control": "private, no-store" } });
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { withApi } from "@/server/api/respond";
|
||||
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
|
||||
@@ -8,20 +9,18 @@ import { requireApiContext, withApi } from "@/server/services/sync/api-context";
|
||||
*/
|
||||
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 });
|
||||
export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
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 });
|
||||
});
|
||||
|
||||
@@ -1,22 +1,13 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJson, withApi } from "@/server/api/respond";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("imports", "import:write", "work_order:write");
|
||||
const { id } = await params;
|
||||
return json(await confirmImport(ctx, id, await readJson(req)));
|
||||
});
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, withApi } from "@/server/api/respond";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export const GET = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("imports", "import:write");
|
||||
const { id } = await params;
|
||||
return json(await getImportDetail(ctx, id));
|
||||
});
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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 });
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, withApi } from "@/server/api/respond";
|
||||
import { approveReport } from "@/server/services/reports/approve";
|
||||
import { reportDto, withReportsApi } from "@/server/services/reports/http";
|
||||
import { reportDto } from "@/server/services/reports/dto";
|
||||
|
||||
/** POST /api/v1/reports/:id/approve — team lead → team_approved, backoffice → approved (+ PDF job). */
|
||||
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
/** POST /api/v1/reports/:id/approve — team lead → team_approved, backoffice → approved (+ PDF job). Approval rights are checked in the service. */
|
||||
export const POST = withApi(async (_req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("reports", "report:read");
|
||||
const { id } = await params;
|
||||
return withReportsApi(["report:read"], async (ctx) => {
|
||||
const report = await approveReport(ctx, { reportId: id });
|
||||
return Response.json({ report: reportDto(report) });
|
||||
});
|
||||
}
|
||||
const report = await approveReport(ctx, { reportId: id });
|
||||
return json({ report: reportDto(report) });
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { withApi } from "@/server/api/respond";
|
||||
import { fileResponse, openReportFile } from "@/server/services/reports/files";
|
||||
import { withReportsApi } from "@/server/services/reports/http";
|
||||
|
||||
/** GET /api/v1/reports/:id/files/:documentId — photo/signature/logo referenced by the report snapshot. */
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string; documentId: string }> }) => {
|
||||
const ctx = await requireApiContext("reports", "report:read");
|
||||
const { id, documentId } = await params;
|
||||
const download = new URL(req.url).searchParams.get("download") === "1";
|
||||
return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, documentId), { download }));
|
||||
}
|
||||
return fileResponse(await openReportFile(ctx, id, documentId), { download });
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { withApi } from "@/server/api/respond";
|
||||
import { fileResponse, openReportFile } from "@/server/services/reports/files";
|
||||
import { withReportsApi } from "@/server/services/reports/http";
|
||||
|
||||
/** GET /api/v1/reports/:id/pdf — the immutable PDF of an approved report (?download=1 for attachment). */
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("reports", "report:read");
|
||||
const { id } = await params;
|
||||
const download = new URL(req.url).searchParams.get("download") === "1";
|
||||
return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, "pdf"), { download }));
|
||||
}
|
||||
return fileResponse(await openReportFile(ctx, id, "pdf"), { download });
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { syncRequestSchema } from "@/lib/sync/envelope";
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { ApiError, json, readJson, withApi } from "@/server/api/respond";
|
||||
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" } });
|
||||
});
|
||||
}
|
||||
export const POST = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("field");
|
||||
const body = syncRequestSchema.safeParse(await readJson(req));
|
||||
if (!body.success) {
|
||||
throw new ApiError("invalid", "invalid sync request", body.error.issues.slice(0, 10).map((i) => ({ path: i.path.join("."), code: i.code })));
|
||||
}
|
||||
return json(await applyOperations(ctx, body.data));
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { ApiError, json, readFormData, withApi } from "@/server/api/respond";
|
||||
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),
|
||||
@@ -8,27 +8,24 @@ import { apiError, requireApiContext, withApi } from "@/server/services/sync/api
|
||||
*/
|
||||
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");
|
||||
export const POST = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("field", "field:execute");
|
||||
const declared = Number(req.headers.get("content-length") ?? "0");
|
||||
if (declared > MAX_BYTES + 3 * 1024 * 1024) throw new ApiError("payload_too_large", "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 form = await readFormData(req);
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) throw new ApiError("invalid", "file missing");
|
||||
if (file.size > MAX_BYTES) throw new ApiError("payload_too_large", "file too large");
|
||||
const meta = uploadMetaSchema.safeParse({ clientId: form.get("clientId"), workOrderId: form.get("workOrderId"), kind: form.get("kind") });
|
||||
if (!meta.success) throw new ApiError("invalid", "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" } });
|
||||
});
|
||||
}
|
||||
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 json(result, { status: result.duplicate ? 200 : 201 });
|
||||
});
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("work_orders", "work_order:assign");
|
||||
const { id } = await params;
|
||||
const body = await readJsonObject(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 json(res);
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
import { createCompletionReport } from "@/server/services/reports/create";
|
||||
import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http";
|
||||
import { reportDto } from "@/server/services/reports/dto";
|
||||
|
||||
/** POST /api/v1/work-orders/:id/completion-report — create (or return) the completion report draft; 422 + blockers if blocked. */
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
/** POST /api/v1/work-orders/:id/completion-report — create (or return) the completion report draft; 422 blocked + blockers if incomplete. */
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("reports", "report:write");
|
||||
const { id } = await params;
|
||||
return withReportsApi(["report:write"], async (ctx) => {
|
||||
const body = await readJson(req);
|
||||
const res = await createCompletionReport(ctx, { ...body, workOrderId: id } as Parameters<typeof createCompletionReport>[1]);
|
||||
return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
|
||||
});
|
||||
}
|
||||
const body = await readJsonObject(req, { allowEmpty: true });
|
||||
const res = await createCompletionReport(ctx, { ...body, workOrderId: id } as Parameters<typeof createCompletionReport>[1]);
|
||||
return json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
import { createDailyReport } from "@/server/services/reports/create";
|
||||
import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http";
|
||||
import { reportDto } from "@/server/services/reports/dto";
|
||||
|
||||
/** POST /api/v1/work-orders/:id/daily-report — create (or return) the daily report draft. Body: { reportDate?, clientId? } */
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("reports", "report:write");
|
||||
const { id } = await params;
|
||||
return withReportsApi(["report:write"], async (ctx) => {
|
||||
const body = await readJson(req);
|
||||
const res = await createDailyReport(ctx, { ...body, workOrderId: id } as Parameters<typeof createDailyReport>[1]);
|
||||
return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
|
||||
});
|
||||
}
|
||||
const body = await readJsonObject(req, { allowEmpty: true });
|
||||
const res = await createDailyReport(ctx, { ...body, workOrderId: id } as Parameters<typeof createDailyReport>[1]);
|
||||
return json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
|
||||
});
|
||||
|
||||
@@ -1,25 +1,26 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import type { DocumentCategory, DocumentVisibility } from "@prisma/client";
|
||||
import { assertSameOrigin, requireApiContext } from "@/server/api/context";
|
||||
import { ApiError, json, readFormData, toErrorResponse } from "@/server/api/respond";
|
||||
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.
|
||||
* Not wrapped in withApi because browser form posts get redirects instead of JSON errors.
|
||||
*/
|
||||
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const origin = new URL(req.url).origin;
|
||||
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();
|
||||
assertSameOrigin(req);
|
||||
const ctx = await requireApiContext("work_orders", "document:write");
|
||||
const form = await readFormData(req);
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing");
|
||||
const title = form.get("title");
|
||||
const doc = await uploadWorkOrderDocument(ctx, {
|
||||
workOrderId: id,
|
||||
bytes: new Uint8Array(await file.arrayBuffer()),
|
||||
@@ -27,14 +28,14 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ id:
|
||||
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,
|
||||
title: typeof title === "string" && title.trim() ? 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 });
|
||||
if (wantsHtml) return Response.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, origin), 303);
|
||||
return 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);
|
||||
if (wantsHtml && (err instanceof ServiceError || (err instanceof ApiError && err.code !== "unauthorized"))) {
|
||||
return Response.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, origin), 303);
|
||||
}
|
||||
return apiError(err);
|
||||
return toErrorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +1,21 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import type { MaterialPlanInput } from "@/lib/work-orders/schemas";
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export const GET = withApi(async (_req: Request, { params }: Params) => {
|
||||
const ctx = await requireApiContext("work_orders");
|
||||
const { id } = await params;
|
||||
return json({ items: await getMaterialOverview(ctx, id) });
|
||||
});
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
export const POST = withApi(async (req: Request, { params }: Params) => {
|
||||
const ctx = await requireApiContext("work_orders", "work_order:write");
|
||||
const { id } = await params;
|
||||
const plan = await addMaterialPlan(ctx, id, (await readJsonObject(req)) as MaterialPlanInput);
|
||||
return json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 });
|
||||
});
|
||||
|
||||
@@ -1,34 +1,25 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import type { UpdateWorkOrderInput } from "@/lib/work-orders/schemas";
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export const GET = withApi(async (_req: Request, { params }: Params) => {
|
||||
const ctx = await requireApiContext("work_orders");
|
||||
const { id } = await params;
|
||||
const wo = await getWorkOrderDetail(ctx, id);
|
||||
const blockers = await computeCompletionBlockers(ctx, id);
|
||||
return json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers });
|
||||
});
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
export const PATCH = withApi(async (req: Request, { params }: Params) => {
|
||||
const ctx = await requireApiContext("work_orders");
|
||||
const { id } = await params;
|
||||
const { baseVersion, ...patch } = await readJsonObject(req);
|
||||
return json(await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion)));
|
||||
});
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, optionalVersion, readJsonObject, withApi } from "@/server/api/respond";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export const POST = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => {
|
||||
const ctx = await requireApiContext("work_orders");
|
||||
const { id } = await params;
|
||||
const body = await readJsonObject(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 json(res);
|
||||
});
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,30 +1,21 @@
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { ApiError, json, readFormData, withApi } from "@/server/api/respond";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export const POST = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("imports", "import:write");
|
||||
const form = await readFormData(req);
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) throw new ApiError("invalid", "file_missing");
|
||||
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 json(current ?? { id: job.id, status: job.status }, { status: 201 });
|
||||
});
|
||||
|
||||
@@ -1,30 +1,22 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { parseListParams } from "@/lib/work-orders/filters";
|
||||
import type { CreateWorkOrderInput } from "@/lib/work-orders/schemas";
|
||||
import { requireApiContext } from "@/server/api/context";
|
||||
import { json, readJsonObject, withApi } from "@/server/api/respond";
|
||||
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);
|
||||
}
|
||||
}
|
||||
export const GET = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("work_orders");
|
||||
return json(await listWorkOrders(ctx, parseListParams(new URL(req.url).searchParams)));
|
||||
});
|
||||
|
||||
/** 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);
|
||||
}
|
||||
}
|
||||
/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). Rights are checked in the service. */
|
||||
export const POST = withApi(async (req: Request) => {
|
||||
const ctx = await requireApiContext("work_orders");
|
||||
const body = await readJsonObject(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 json(created, { status: 201 });
|
||||
});
|
||||
|
||||
@@ -43,12 +43,14 @@ export function ImportUploader() {
|
||||
if (e.lengthComputable) setState({ kind: "uploading", percent: Math.round((e.loaded / e.total) * 100), name: file.name });
|
||||
};
|
||||
xhr.onload = () => {
|
||||
const res = (xhr.response ?? {}) as { id?: string; error?: string; message?: string };
|
||||
// Unified /api/v1 error format: { error: { code, message, details? } }
|
||||
const res = (xhr.response ?? {}) as { id?: string; error?: { code?: string; message?: string } };
|
||||
if (xhr.status === 201 && res.id) {
|
||||
setState({ kind: "done", name: file.name, id: res.id });
|
||||
router.refresh();
|
||||
} else {
|
||||
const code = res.message && KNOWN_ERRORS.has(res.message) ? res.message : res.error && KNOWN_ERRORS.has(res.error) ? res.error : "error";
|
||||
const { code: errCode, message } = res.error ?? {};
|
||||
const code = message && KNOWN_ERRORS.has(message) ? message : errCode && KNOWN_ERRORS.has(errCode) ? errCode : "error";
|
||||
setState({ kind: "error", code });
|
||||
}
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
|
||||
@@ -40,7 +40,7 @@ export function uploadFieldFile(opts: {
|
||||
}
|
||||
return;
|
||||
}
|
||||
const error = xhr.status === 400 || xhr.status === 413 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal";
|
||||
const error = xhr.status === 400 || xhr.status === 413 || xhr.status === 422 ? "invalid" : xhr.status === 403 || xhr.status === 401 ? "forbidden" : xhr.status === 404 ? "not_found" : "internal";
|
||||
resolve({ ok: false, error });
|
||||
};
|
||||
xhr.send(form);
|
||||
|
||||
@@ -141,7 +141,8 @@ const browserTransport: Transport = {
|
||||
return;
|
||||
}
|
||||
if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" });
|
||||
if (xhr.status === 400 || xhr.status === 413) return resolve({ ok: false, error: "invalid" });
|
||||
// 422 = unified /api/v1 validation error; 429 (rate limit) stays transient → backoff
|
||||
if (xhr.status === 400 || xhr.status === 413 || xhr.status === 422) return resolve({ ok: false, error: "invalid" });
|
||||
if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" });
|
||||
if (xhr.status === 404) return resolve({ ok: false, error: "not_found" });
|
||||
resolve({ ok: false, error: xhr.status === 0 ? "network" : "internal" });
|
||||
|
||||
+16
-15
@@ -7,6 +7,11 @@ import type { Permission } from "@/server/rbac";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { ApiError } from "@/server/api/respond";
|
||||
import { consumeRateLimit } from "@/server/rate-limit";
|
||||
|
||||
// assertSameOrigin lives in respond.ts (withApi applies it to every mutation); re-exported for
|
||||
// route handlers that do not use withApi (e.g. /documents/upload with HTML redirects).
|
||||
export { assertSameOrigin } from "@/server/api/respond";
|
||||
|
||||
/**
|
||||
* Service context for /api/v1 route handlers and other route handlers (e.g. /files/<id>).
|
||||
@@ -57,27 +62,23 @@ export async function requireApiContext(moduleKey: ModuleKey | null, ...permissi
|
||||
}
|
||||
}
|
||||
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
|
||||
if (moduleKey) enforceApiRateLimit(session.user.id, moduleKey);
|
||||
|
||||
return { db, tenantId, userId: session.user.id, permissions: effective };
|
||||
}
|
||||
|
||||
/**
|
||||
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
|
||||
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
|
||||
* Per-user request budget for /api/v1 (in-memory, per app instance — see rate-limit.ts).
|
||||
* Field endpoints (sync outbox, uploads, offline pre-download, document cache) get the generous
|
||||
* `apiField` bucket, everything else `api`. `moduleKey = null` callers (/files downloads, the
|
||||
* EXEMPT lotse-settings action) are not /api/v1 endpoints and are not counted.
|
||||
* Exceeded → ApiError `rate_limited` (429 + Retry-After).
|
||||
*/
|
||||
export function assertSameOrigin(req: Request): void {
|
||||
const site = req.headers.get("sec-fetch-site");
|
||||
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
|
||||
const origin = req.headers.get("origin");
|
||||
if (origin) {
|
||||
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
|
||||
let originHost: string | null = null;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
originHost = null;
|
||||
}
|
||||
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
|
||||
export function enforceApiRateLimit(userId: string, moduleKey: ModuleKey): void {
|
||||
const scope = moduleKey === "field" ? "apiField" : "api";
|
||||
const res = consumeRateLimit(scope, userId);
|
||||
if (!res.allowed) {
|
||||
throw new ApiError("rate_limited", "too many requests", { retryAfterSeconds: res.retryAfterSeconds });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
-11
@@ -4,10 +4,12 @@ import { ForbiddenError } from "@/server/rbac";
|
||||
import { ModuleDisabledError } from "@/server/modules";
|
||||
|
||||
/**
|
||||
* JSON response helpers for /api/v1 route handlers (spec §29.2).
|
||||
* JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter —
|
||||
* the former lane-local variants in imports/_context.ts, sync/api-context.ts, reports/http.ts and
|
||||
* work-orders/_http.ts are gone).
|
||||
* Error format: `{ error: { code, message, details? } }`; list format:
|
||||
* `{ data: [...], pagination: { page, pageSize, total } }`.
|
||||
* Internal error details never leave the server (CWE-209).
|
||||
* Internal error details never leave the server (CWE-209). Documented in docs/craftvia/API.md.
|
||||
*/
|
||||
|
||||
export type ApiErrorCode =
|
||||
@@ -18,16 +20,19 @@ export type ApiErrorCode =
|
||||
| "conflict"
|
||||
| "blocked"
|
||||
| "payload_too_large"
|
||||
| "rate_limited"
|
||||
| "internal";
|
||||
|
||||
const STATUS: Record<ApiErrorCode, number> = {
|
||||
export const API_ERROR_STATUS: Record<ApiErrorCode, number> = {
|
||||
unauthorized: 401,
|
||||
forbidden: 403,
|
||||
not_found: 404,
|
||||
invalid: 422,
|
||||
conflict: 409,
|
||||
blocked: 409,
|
||||
// domain rule prevents the action (e.g. completion blockers) — request itself is well-formed
|
||||
blocked: 422,
|
||||
payload_too_large: 413,
|
||||
rate_limited: 429,
|
||||
internal: 500,
|
||||
};
|
||||
|
||||
@@ -42,16 +47,22 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response {
|
||||
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown, headers?: Record<string, string>): Response {
|
||||
return Response.json(
|
||||
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
|
||||
{ status: STATUS[code], headers: { "Cache-Control": "no-store" } },
|
||||
{ status: API_ERROR_STATUS[code], headers: { "Cache-Control": "no-store", ...headers } },
|
||||
);
|
||||
}
|
||||
|
||||
/** Map any thrown error to a JSON error response. */
|
||||
export function toErrorResponse(err: unknown): Response {
|
||||
if (err instanceof ApiError) return errorResponse(err.code, err.message, err.details);
|
||||
if (err instanceof ApiError) {
|
||||
if (err.code === "rate_limited") {
|
||||
const retry = (err.details as { retryAfterSeconds?: number } | undefined)?.retryAfterSeconds ?? 60;
|
||||
return errorResponse(err.code, err.message, err.details, { "Retry-After": String(retry) });
|
||||
}
|
||||
return errorResponse(err.code, err.message, err.details);
|
||||
}
|
||||
if (err instanceof ServiceError) return errorResponse(err.code, err.message, err.details);
|
||||
if (err instanceof ZodError) {
|
||||
return errorResponse(
|
||||
@@ -67,8 +78,8 @@ export function toErrorResponse(err: unknown): Response {
|
||||
return errorResponse("internal", "internal error");
|
||||
}
|
||||
|
||||
export function json(data: unknown, init?: { status?: number }): Response {
|
||||
return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store" } });
|
||||
export function json(data: unknown, init?: { status?: number; headers?: Record<string, string> }): Response {
|
||||
return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store", ...init?.headers } });
|
||||
}
|
||||
|
||||
export function paginated<T>(items: T[], total: number, page: number, pageSize: number): Response {
|
||||
@@ -83,10 +94,38 @@ export function parsePagination(url: URL | string, defaults = { pageSize: 25 }):
|
||||
return { page, pageSize };
|
||||
}
|
||||
|
||||
/** Wrap a handler so every thrown error becomes a JSON error response. */
|
||||
export function withApi<A extends unknown[]>(handler: (...args: A) => Promise<Response>) {
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
/**
|
||||
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
|
||||
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
|
||||
* Requests without Origin/Sec-Fetch-Site (server-to-server clients, curl) pass — they carry no
|
||||
* ambient browser cookies of a victim.
|
||||
*/
|
||||
export function assertSameOrigin(req: Request): void {
|
||||
const site = req.headers.get("sec-fetch-site");
|
||||
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
|
||||
const origin = req.headers.get("origin");
|
||||
if (origin) {
|
||||
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
|
||||
let originHost: string | null = null;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
originHost = null;
|
||||
}
|
||||
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a handler: same-origin check for every non-safe method (before authentication, so a
|
||||
* cross-site request never reaches a service) + every thrown error becomes a JSON error response.
|
||||
*/
|
||||
export function withApi<A extends [Request, ...unknown[]]>(handler: (...args: A) => Promise<Response>) {
|
||||
return async (...args: A): Promise<Response> => {
|
||||
try {
|
||||
if (!SAFE_METHODS.has(args[0].method.toUpperCase())) assertSameOrigin(args[0]);
|
||||
return await handler(...args);
|
||||
} catch (err) {
|
||||
return toErrorResponse(err);
|
||||
@@ -102,3 +141,37 @@ export async function readJson(req: Request): Promise<unknown> {
|
||||
throw new ApiError("invalid", "malformed JSON body");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a JSON object body. Arrays/primitives → 422. With `allowEmpty` an empty body is `{}`
|
||||
* (endpoints whose body fields are all optional).
|
||||
*/
|
||||
export async function readJsonObject(req: Request, opts: { allowEmpty?: boolean } = {}): Promise<Record<string, unknown>> {
|
||||
const text = await req.text();
|
||||
if (!text.trim()) {
|
||||
if (opts.allowEmpty) return {};
|
||||
throw new ApiError("invalid", "JSON body required");
|
||||
}
|
||||
let body: unknown;
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
throw new ApiError("invalid", "malformed JSON body");
|
||||
}
|
||||
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ApiError("invalid", "JSON object expected");
|
||||
return body as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Read a multipart body; anything else → 422. */
|
||||
export async function readFormData(req: Request): Promise<FormData> {
|
||||
try {
|
||||
return await req.formData();
|
||||
} catch {
|
||||
throw new ApiError("invalid", "multipart body expected");
|
||||
}
|
||||
}
|
||||
|
||||
/** Optimistic-locking version from a body field (positive integer), otherwise undefined. */
|
||||
export function optionalVersion(v: unknown): number | undefined {
|
||||
return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ function keyOf(scope: string, identifier: string): string {
|
||||
|
||||
export type RateLimitRule = { limit: number; windowMs: number };
|
||||
|
||||
/** Positive integer from the environment, otherwise the default (read once at module load). */
|
||||
function perMinute(name: string, fallback: number): number {
|
||||
const v = Number(process.env[name]);
|
||||
return Number.isInteger(v) && v > 0 ? v : fallback;
|
||||
}
|
||||
|
||||
/** Voreinstellungen der sicherheitskritischen Aktionen (SEC2 §6). */
|
||||
export const RATE_LIMITS = {
|
||||
/** Reset-Anfrage: 5 pro Stunde je IP und je Konto. */
|
||||
@@ -50,6 +56,10 @@ export const RATE_LIMITS = {
|
||||
passwordVerify: { limit: 10, windowMs: 15 * 60_000 },
|
||||
/** Anforderung einer E-Mail-Änderung. */
|
||||
emailChangeRequest: { limit: 5, windowMs: 60 * 60_000 },
|
||||
/** L10b: /api/v1/** je Nutzer (Integrationen, Backoffice-Clients). */
|
||||
api: { limit: perMinute("API_RATE_LIMIT_PER_MINUTE", 300), windowMs: 60_000 },
|
||||
/** L10b: Einsatz-/Sync-Endpunkte (/sync, /uploads, /field/**) je Nutzer — großzügig (Outbox, Vorab-Download). */
|
||||
apiField: { limit: perMinute("API_FIELD_RATE_LIMIT_PER_MINUTE", 1200), windowMs: 60_000 },
|
||||
} as const satisfies Record<string, RateLimitRule>;
|
||||
|
||||
export type RateLimitScope = keyof typeof RATE_LIMITS;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Public shape of a report in /api/v1 responses (no snapshot content). */
|
||||
export function reportDto(r: { id: string; type: string; status: string; version: number; workOrderId: string; lineageId: string; pdfDocumentId?: string | null }) {
|
||||
return { id: r.id, type: r.type, status: r.status, version: r.version, workOrderId: r.workOrderId, lineageId: r.lineageId, hasPdf: Boolean(r.pdfDocumentId) };
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { ZodError } from "zod";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Thin adapter for /api/v1 report route handlers: module gate + DB-authoritative permissions → ServiceCtx,
|
||||
* uniform JSON error mapping. (No shared requireApiContext exists yet; replace at merge if the architect adds one.)
|
||||
*/
|
||||
const guard = moduleGuard("reports");
|
||||
|
||||
const STATUS: Record<ServiceError["code"], number> = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 422 };
|
||||
|
||||
export function apiError(err: unknown): Response {
|
||||
if (err instanceof ServiceError) return Response.json({ error: err.code, details: err.details ?? null }, { status: STATUS[err.code] });
|
||||
if (err instanceof ZodError) return Response.json({ error: "invalid", details: err.issues.map((i) => ({ path: i.path.join("."), code: i.code })) }, { status: 400 });
|
||||
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
if (err instanceof Error && /Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(err.message)) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error("[api/reports]", err);
|
||||
return Response.json({ error: "internal" }, { status: 500 });
|
||||
}
|
||||
|
||||
export async function withReportsApi(permissions: Permission[], handler: (ctx: ServiceCtx) => Promise<Response>): Promise<Response> {
|
||||
try {
|
||||
const g = await guard(...permissions);
|
||||
return await handler(ctxFromGuard(g));
|
||||
} catch (err) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJson(req: Request): Promise<Record<string, unknown>> {
|
||||
const text = await req.text();
|
||||
if (!text.trim()) return {};
|
||||
try {
|
||||
const v = JSON.parse(text);
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
||||
} catch {
|
||||
throw new ServiceError("invalid", "body must be JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export function reportDto(r: { id: string; type: string; status: string; version: number; workOrderId: string; lineageId: string; pdfDocumentId?: string | null }) {
|
||||
return { id: r.id, type: r.type, status: r.status, version: r.version, workOrderId: r.workOrderId, lineageId: r.lineageId, hasPdf: Boolean(r.pdfDocumentId) };
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
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";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
|
||||
/**
|
||||
* Context for /api/v1 route handlers (lane L4: sync, uploads, field). Reuses moduleGuard, so
|
||||
* route handlers get exactly the same DB-authoritative checks as server actions (session, account
|
||||
* status, kill switch, password change, permissions, module enabled).
|
||||
* NOTE for the architect: a shared `requireApiContext` is referenced in services/context.ts but not
|
||||
* provided by the foundation — this is the lane-local implementation.
|
||||
*/
|
||||
export async function requireApiContext(moduleKey: ModuleKey, ...permissions: Permission[]): Promise<ServiceCtx> {
|
||||
return ctxFromGuard(await moduleGuard(moduleKey)(...permissions));
|
||||
}
|
||||
|
||||
const STATUS_FOR: Record<ServiceError["code"], number> = {
|
||||
not_found: 404,
|
||||
forbidden: 403,
|
||||
invalid: 400,
|
||||
conflict: 409,
|
||||
blocked: 422,
|
||||
};
|
||||
|
||||
export function apiError(code: string, status: number, message?: string, details?: unknown) {
|
||||
return NextResponse.json({ error: code, ...(message ? { message } : {}), ...(details !== undefined ? { details } : {}) }, { status, headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
/** Wraps a handler: same-origin check for mutations + uniform error mapping (no internals leaked). */
|
||||
export async function withApi(req: Request, fn: () => Promise<Response>): Promise<Response> {
|
||||
if (req.method !== "GET" && req.method !== "HEAD") {
|
||||
const origin = req.headers.get("origin");
|
||||
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
|
||||
if (origin && host) {
|
||||
let originHost: string | null = null;
|
||||
try {
|
||||
originHost = new URL(origin).host;
|
||||
} catch {
|
||||
originHost = null;
|
||||
}
|
||||
if (originHost !== host) return apiError("forbidden", 403, "cross-origin request");
|
||||
}
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError) return apiError(err.code, STATUS_FOR[err.code], err.message, err.code === "blocked" ? err.details : undefined);
|
||||
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return apiError("forbidden", 403);
|
||||
const msg = err instanceof Error ? err.message : "";
|
||||
if (/Nicht angemeldet|nicht mehr gueltig/.test(msg)) return apiError("unauthorized", 401);
|
||||
if (/Konto ist nicht aktiv|Passwortwechsel erforderlich/.test(msg)) return apiError("forbidden", 403);
|
||||
console.error("[api/v1] unhandled error:", err);
|
||||
return apiError("internal", 500);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user