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>
105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
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");
|
|
}
|
|
}
|