L1 Stammdaten: Services, Dublettenprüfung, Server Actions und API v1
Kunden (Nummernkreis, Ansprechpartner, vorläufig bestätigen, Zusammenführen mit Bestätigung), Objekte inkl. Historie, Teams mit Mitgliedschaften, Dublettenlogik (lib + Service), API-Kontext/Antwortformat unter src/server/api und die Endpunkte /api/v1/customers, /api/v1/sites, /api/v1/sites/[id]/history. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { ZodError } from "zod";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { ForbiddenError } from "@/server/rbac";
|
||||
import { ModuleDisabledError } from "@/server/modules";
|
||||
|
||||
/**
|
||||
* JSON response helpers for /api/v1 route handlers (spec §29.2).
|
||||
* Error format: `{ error: { code, message, details? } }`; list format:
|
||||
* `{ data: [...], pagination: { page, pageSize, total } }`.
|
||||
* Internal error details never leave the server (CWE-209).
|
||||
*/
|
||||
|
||||
export type ApiErrorCode =
|
||||
| "unauthorized"
|
||||
| "forbidden"
|
||||
| "not_found"
|
||||
| "invalid"
|
||||
| "conflict"
|
||||
| "blocked"
|
||||
| "payload_too_large"
|
||||
| "internal";
|
||||
|
||||
const STATUS: Record<ApiErrorCode, number> = {
|
||||
unauthorized: 401,
|
||||
forbidden: 403,
|
||||
not_found: 404,
|
||||
invalid: 422,
|
||||
conflict: 409,
|
||||
blocked: 409,
|
||||
payload_too_large: 413,
|
||||
internal: 500,
|
||||
};
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public code: ApiErrorCode,
|
||||
message: string,
|
||||
public details?: unknown,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
}
|
||||
}
|
||||
|
||||
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response {
|
||||
return Response.json(
|
||||
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
|
||||
{ status: STATUS[code], headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 ServiceError) return errorResponse(err.code, err.message, err.details);
|
||||
if (err instanceof ZodError) {
|
||||
return errorResponse(
|
||||
"invalid",
|
||||
"validation failed",
|
||||
err.issues.map((i) => ({ path: i.path.join("."), code: i.code })),
|
||||
);
|
||||
}
|
||||
if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden");
|
||||
if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled");
|
||||
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found");
|
||||
console.error("[api] unhandled error", err);
|
||||
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 paginated<T>(items: T[], total: number, page: number, pageSize: number): Response {
|
||||
return json({ data: items, pagination: { page, pageSize, total } });
|
||||
}
|
||||
|
||||
/** `?page&pageSize` with sane bounds (pageSize 1..100, default 25). */
|
||||
export function parsePagination(url: URL | string, defaults = { pageSize: 25 }): { page: number; pageSize: number } {
|
||||
const u = typeof url === "string" ? new URL(url) : url;
|
||||
const page = Math.max(1, Math.floor(Number(u.searchParams.get("page")) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(u.searchParams.get("pageSize")) || defaults.pageSize)));
|
||||
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>) {
|
||||
return async (...args: A): Promise<Response> => {
|
||||
try {
|
||||
return await handler(...args);
|
||||
} catch (err) {
|
||||
return toErrorResponse(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Read a JSON body; malformed JSON → 422. */
|
||||
export async function readJson(req: Request): Promise<unknown> {
|
||||
try {
|
||||
return await req.json();
|
||||
} catch {
|
||||
throw new ApiError("invalid", "malformed JSON body");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user