- src/lib/api/openapi.ts: statisch gepflegte Spezifikation aller v1-Routen inkl. Fehlerformat, Pagination, Idempotenz (clientOpId/clientId), Konflikte, Rate Limits, Rechte je Operation. - GET /api/v1/openapi.json liefert das Dokument (angemeldete Nutzer). - docs/craftvia/API.md: Kurzdoku mit Endpunkt-Tabelle. - scripts/test-betrieb-api.ts: jede Route nutzt requireApiContext/respond.ts, 401 ohne Sitzung im einheitlichen Format, 403 bei fremdem Origin/Sec-Fetch-Site, Fehler-Mapping, Rate Limit je Nutzer (Standard/Einsatz getrennt), OpenAPI deckt jede route.ts ab. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1136 lines
53 KiB
TypeScript
1136 lines
53 KiB
TypeScript
/**
|
||
* Statically maintained OpenAPI 3.1 description of every route handler under
|
||
* src/app/api/v1/** (served by GET /api/v1/openapi.json, human summary in docs/craftvia/API.md).
|
||
*
|
||
* Maintenance rule: a new/changed route.ts must be reflected here — `API_ROUTES` lists the
|
||
* documented paths (full `/api/v1` prefix, `{param}` = `[param]` folder) so a test can compare
|
||
* them with the file system. Schemas describe the core fields read from the services/Zod
|
||
* schemas; entity objects stay open (`additionalProperties: true`) where Prisma rows are returned.
|
||
* Client-safe: no server imports.
|
||
*/
|
||
|
||
type Schema = Record<string, unknown>;
|
||
|
||
// ---------- small builders ----------
|
||
|
||
const ref = (name: string): Schema => ({ $ref: `#/components/schemas/${name}` });
|
||
const str = (extra: Schema = {}): Schema => ({ type: "string", ...extra });
|
||
const nstr = (extra: Schema = {}): Schema => ({ type: ["string", "null"], ...extra });
|
||
const int = (extra: Schema = {}): Schema => ({ type: "integer", ...extra });
|
||
const num = (extra: Schema = {}): Schema => ({ type: "number", ...extra });
|
||
const bool = (extra: Schema = {}): Schema => ({ type: "boolean", ...extra });
|
||
const dateTime = (extra: Schema = {}): Schema => ({ type: "string", format: "date-time", ...extra });
|
||
const nDateTime = (extra: Schema = {}): Schema => ({ type: ["string", "null"], format: "date-time", ...extra });
|
||
const arr = (items: Schema, extra: Schema = {}): Schema => ({ type: "array", items, ...extra });
|
||
const obj = (properties: Record<string, Schema>, required: string[] = [], extra: Schema = {}): Schema => ({
|
||
type: "object",
|
||
properties,
|
||
...(required.length ? { required } : {}),
|
||
...extra,
|
||
});
|
||
const open = (properties: Record<string, Schema>, required: string[] = []): Schema => obj(properties, required, { additionalProperties: true });
|
||
|
||
const jsonBody = (schema: Schema, required = true): Schema => ({ required, content: { "application/json": { schema } } });
|
||
const jsonResponse = (description: string, schema: Schema, headers?: Schema): Schema => ({
|
||
description,
|
||
...(headers ? { headers } : {}),
|
||
content: { "application/json": { schema } },
|
||
});
|
||
const binaryResponse = (description: string): Schema => ({
|
||
description,
|
||
headers: {
|
||
"Content-Disposition": { schema: str(), description: "`inline` bzw. `attachment; filename=\"…\"`" },
|
||
"X-Content-Type-Options": { schema: str({ const: "nosniff" }) },
|
||
},
|
||
content: { "application/octet-stream": { schema: str({ contentMediaType: "application/octet-stream" }) } },
|
||
});
|
||
|
||
const pathParam = (name: string, description: string): Schema => ({ name, in: "path", required: true, schema: str({ maxLength: 64 }), description });
|
||
const query = (name: string, schema: Schema, description?: string): Schema => ({ name, in: "query", required: false, schema, ...(description ? { description } : {}) });
|
||
|
||
type ErrorKey = "unauthorized" | "forbidden" | "not_found" | "conflict" | "unprocessable" | "payload_too_large" | "rate_limited" | "internal";
|
||
const ERROR_RESPONSES: Record<ErrorKey, [string, string]> = {
|
||
unauthorized: ["401", "Unauthorized"],
|
||
forbidden: ["403", "Forbidden"],
|
||
not_found: ["404", "NotFound"],
|
||
conflict: ["409", "Conflict"],
|
||
unprocessable: ["422", "Unprocessable"],
|
||
payload_too_large: ["413", "PayloadTooLarge"],
|
||
rate_limited: ["429", "RateLimited"],
|
||
internal: ["500", "Internal"],
|
||
};
|
||
/** Standard error responses (401/403/429/500 always) plus the given extras. */
|
||
function errors(...extra: ErrorKey[]): Record<string, Schema> {
|
||
const keys: ErrorKey[] = ["unauthorized", "forbidden", ...extra, "rate_limited", "internal"];
|
||
const out: Record<string, Schema> = {};
|
||
for (const k of keys) {
|
||
const [status, name] = ERROR_RESPONSES[k];
|
||
out[status] = { $ref: `#/components/responses/${name}` };
|
||
}
|
||
return out;
|
||
}
|
||
|
||
type Op = {
|
||
tag: string;
|
||
summary: string;
|
||
description?: string;
|
||
operationId: string;
|
||
/** `x-craftvia-module` / `x-craftvia-permissions` document the route-level gate. */
|
||
module: string | null;
|
||
permissions: string[];
|
||
parameters?: Schema[];
|
||
requestBody?: Schema;
|
||
responses: Record<string, Schema>;
|
||
security?: Schema[];
|
||
};
|
||
const op = (o: Op): Schema => ({
|
||
tags: [o.tag],
|
||
summary: o.summary,
|
||
...(o.description ? { description: o.description } : {}),
|
||
operationId: o.operationId,
|
||
"x-craftvia-module": o.module,
|
||
"x-craftvia-permissions": o.permissions,
|
||
...(o.parameters ? { parameters: o.parameters } : {}),
|
||
...(o.requestBody ? { requestBody: o.requestBody } : {}),
|
||
responses: o.responses,
|
||
...(o.security ? { security: o.security } : {}),
|
||
});
|
||
|
||
// ---------- enums (mirrors of client-safe constants) ----------
|
||
|
||
const WORK_ORDER_STATUSES = [
|
||
"draft",
|
||
"review_required",
|
||
"planned",
|
||
"assigned",
|
||
"accepted",
|
||
"en_route",
|
||
"in_progress",
|
||
"paused",
|
||
"waiting_material",
|
||
"daily_report_created",
|
||
"technically_completed",
|
||
"signature_pending",
|
||
"in_review",
|
||
"released_for_billing",
|
||
"billed",
|
||
"cancelled",
|
||
];
|
||
const STATUS_GROUPS = ["new", "planned", "en_route", "in_progress", "documentation_incomplete", "in_review", "ready_for_billing", "billed", "cancelled"];
|
||
const PRESETS = ["open", "today", "running", "not_accepted", "overdue", "reports_in_review", "completed", "billing", "emergency_new", "missing_signatures"];
|
||
const SORT_FIELDS = ["plannedStart", "createdAt", "updatedAt", "number", "priority", "status"];
|
||
const PRIORITIES = ["low", "normal", "high", "urgent"];
|
||
const BILLING_TYPES = ["fixed", "time_material", "maintenance_contract", "warranty"];
|
||
const SYNC_OP_TYPES = [
|
||
"session.start",
|
||
"session.pause",
|
||
"session.resume",
|
||
"session.end",
|
||
"work_order.transition",
|
||
"note.create",
|
||
"checklist.toggle",
|
||
"material.upsert",
|
||
"photo.attach",
|
||
"voice.attach",
|
||
"report.save_draft",
|
||
"report.submit",
|
||
"signature.capture",
|
||
"emergency.create",
|
||
];
|
||
const NOTE_KINDS = ["work_done", "deviation", "problem", "additional_work", "not_executable", "follow_up", "recommendation", "customer_note", "general"];
|
||
const UPLOAD_CATEGORIES = [
|
||
"order_confirmation",
|
||
"technical_drawing",
|
||
"floor_plan",
|
||
"wiring_diagram",
|
||
"assembly_instructions",
|
||
"safety_document",
|
||
"product_document",
|
||
"customer_note",
|
||
"other",
|
||
];
|
||
const DOCUMENT_VISIBILITIES = ["backoffice_only", "team_lead", "team", "customer_report"];
|
||
const IMPORT_STATUSES = ["uploaded", "processing", "review_required", "confirmed", "failed", "discarded"];
|
||
|
||
// ---------- component schemas ----------
|
||
|
||
const customerFields: Record<string, Schema> = {
|
||
customerNumber: nstr({ maxLength: 40 }),
|
||
companyName: nstr({ maxLength: 200 }),
|
||
salutation: nstr({ maxLength: 40 }),
|
||
firstName: nstr({ maxLength: 100 }),
|
||
lastName: nstr({ maxLength: 100 }),
|
||
street: nstr({ maxLength: 200 }),
|
||
houseNumber: nstr({ maxLength: 20 }),
|
||
postalCode: nstr({ maxLength: 12 }),
|
||
city: nstr({ maxLength: 100 }),
|
||
country: str({ pattern: "^[A-Z]{2}$", description: "ISO 3166-1 alpha-2; Kleinbuchstaben werden normalisiert." }),
|
||
phone: nstr({ maxLength: 50 }),
|
||
mobile: nstr({ maxLength: 50 }),
|
||
email: nstr({ format: "email", maxLength: 200 }),
|
||
notes: nstr({ maxLength: 5000 }),
|
||
billingNotes: nstr({ maxLength: 5000 }),
|
||
status: str({ enum: ["active", "inactive", "provisional"] }),
|
||
};
|
||
|
||
const siteFields: Record<string, Schema> = {
|
||
customerId: str({ minLength: 1 }),
|
||
name: str({ minLength: 1, maxLength: 200 }),
|
||
street: nstr({ maxLength: 200 }),
|
||
houseNumber: nstr({ maxLength: 20 }),
|
||
postalCode: nstr({ maxLength: 12 }),
|
||
city: nstr({ maxLength: 100 }),
|
||
country: str({ pattern: "^[A-Z]{2}$", description: "Default DE" }),
|
||
contactId: nstr({ maxLength: 64, description: "Muss zum Kunden gehören." }),
|
||
onSiteContact: nstr({ maxLength: 200 }),
|
||
phone: nstr({ maxLength: 50 }),
|
||
accessNotes: nstr({ maxLength: 5000 }),
|
||
parkingNotes: nstr({ maxLength: 5000 }),
|
||
safetyNotes: nstr({ maxLength: 5000 }),
|
||
technicalNotes: nstr({ maxLength: 5000 }),
|
||
status: str({ enum: ["active", "inactive", "provisional"] }),
|
||
latitude: { type: ["number", "null"], minimum: -90, maximum: 90 },
|
||
longitude: { type: ["number", "null"], minimum: -180, maximum: 180 },
|
||
};
|
||
|
||
const addressRef = open({ id: str(), name: str(), street: nstr(), houseNumber: nstr(), postalCode: nstr(), city: nstr() });
|
||
const personRef = open({ id: str(), companyName: nstr(), firstName: nstr(), lastName: nstr() });
|
||
const idName = obj({ id: str(), name: str() }, ["id", "name"]);
|
||
|
||
const materialPlanInput = obj(
|
||
{
|
||
name: str({ minLength: 1, maxLength: 200 }),
|
||
articleNumber: nstr({ maxLength: 80 }),
|
||
plannedQuantity: num({ exclusiveMinimum: 0, maximum: 1_000_000 }),
|
||
unit: str({ minLength: 1, maxLength: 20 }),
|
||
notes: nstr({ maxLength: 1000 }),
|
||
sortOrder: int({ minimum: 0, maximum: 10_000 }),
|
||
},
|
||
["name", "plannedQuantity", "unit"],
|
||
);
|
||
|
||
const workOrderMasterFields: Record<string, Schema> = {
|
||
title: str({ minLength: 1, maxLength: 200 }),
|
||
customerId: str({ maxLength: 64 }),
|
||
siteId: nstr({ maxLength: 64 }),
|
||
contactId: nstr({ maxLength: 64 }),
|
||
orderTypeId: nstr({ maxLength: 64 }),
|
||
priority: ref("WorkOrderPriority"),
|
||
description: nstr({ maxLength: 10_000 }),
|
||
scope: nstr({ maxLength: 10_000 }),
|
||
plannedStart: nDateTime(),
|
||
plannedEnd: nDateTime({ description: "Darf nicht vor plannedStart liegen." }),
|
||
signatureRequired: bool({ description: "Fehlt → Vorgabe des Auftragstyps (Default true)." }),
|
||
billingType: { type: ["string", "null"], enum: [...BILLING_TYPES, null] },
|
||
internalNotes: nstr({ maxLength: 5000 }),
|
||
technicianNotes: nstr({ maxLength: 5000 }),
|
||
externalOrderNumber: nstr({ maxLength: 80 }),
|
||
offerNumber: nstr({ maxLength: 80 }),
|
||
emergencyReason: nstr({ maxLength: 2000 }),
|
||
};
|
||
|
||
const isoDateTimeOffset = dateTime({ description: "ISO 8601 mit Zeitzonen-Offset" });
|
||
const opId = str({ minLength: 1, maxLength: 64 });
|
||
const uuid = str({ format: "uuid" });
|
||
|
||
const schemas: Record<string, Schema> = {
|
||
Error: obj(
|
||
{
|
||
error: obj(
|
||
{
|
||
code: str({ enum: ["unauthorized", "forbidden", "not_found", "conflict", "invalid", "blocked", "payload_too_large", "rate_limited", "internal"] }),
|
||
message: str(),
|
||
details: { description: "Optional; je Code: invalid → ValidationIssue[], blocked → z. B. CompletionBlocker[], conflict → z. B. { reason, candidates }, rate_limited → { retryAfterSeconds }." },
|
||
},
|
||
["code", "message"],
|
||
),
|
||
},
|
||
["error"],
|
||
),
|
||
ValidationIssue: obj({ path: str({ description: "Punkt-getrennter Feldpfad" }), code: str({ description: "Zod-Issue-Code" }) }, ["path", "code"]),
|
||
Pagination: obj({ page: int({ minimum: 1 }), pageSize: int({ minimum: 1, maximum: 100 }), total: int({ minimum: 0 }) }, ["page", "pageSize", "total"]),
|
||
CompletionBlocker: {
|
||
oneOf: [
|
||
obj({ kind: str({ const: "checklist_item" }), itemId: str(), label: str() }, ["kind", "itemId", "label"]),
|
||
obj({ kind: str({ const: "photo_requirement" }), requirementId: str(), label: str() }, ["kind", "requirementId", "label"]),
|
||
obj({ kind: str({ const: "running_session" }), sessionId: str(), userId: str() }, ["kind", "sessionId", "userId"]),
|
||
obj({ kind: str({ const: "missing_field" }), field: str() }, ["kind", "field"]),
|
||
],
|
||
},
|
||
WorkOrderStatus: str({ enum: WORK_ORDER_STATUSES }),
|
||
WorkOrderPriority: str({ enum: PRIORITIES }),
|
||
StatusGroup: str({ enum: STATUS_GROUPS }),
|
||
|
||
// --- customers ---
|
||
CustomerCreate: obj({ ...customerFields, acknowledgeDuplicates: bool({ description: "true übergeht die Dubletten-Prüfung (sonst 409 possible_duplicates)." }) }, [], {
|
||
description: "companyName oder lastName ist Pflicht. Leere Strings werden zu null.",
|
||
}),
|
||
CustomerPatch: obj(customerFields, [], { description: "Fehlende Felder bleiben unverändert, null leert das Feld." }),
|
||
CustomerListItem: open(
|
||
{
|
||
id: str(),
|
||
customerNumber: nstr(),
|
||
companyName: nstr(),
|
||
salutation: nstr(),
|
||
firstName: nstr(),
|
||
lastName: nstr(),
|
||
postalCode: nstr(),
|
||
city: nstr(),
|
||
phone: nstr(),
|
||
email: nstr(),
|
||
status: str({ enum: ["active", "inactive", "provisional", "merged"] }),
|
||
updatedAt: dateTime(),
|
||
_count: obj({ sites: int() }),
|
||
},
|
||
["id", "status"],
|
||
),
|
||
Contact: open({ id: str(), name: str(), role: nstr(), phone: nstr(), mobile: nstr(), email: nstr(), preferredChannel: { type: ["string", "null"], enum: ["phone", "mobile", "email", null] }, notes: nstr() }, ["id", "name"]),
|
||
Customer: open({ id: str(), ...customerFields, status: str({ enum: ["active", "inactive", "provisional", "merged"] }), createdAt: dateTime(), updatedAt: dateTime() }, ["id", "status"]),
|
||
CustomerWithContacts: { allOf: [ref("Customer"), obj({ contacts: arr(ref("Contact")) })] },
|
||
|
||
// --- sites ---
|
||
SiteCreate: obj(siteFields, ["customerId", "name"]),
|
||
Site: open({ id: str(), ...siteFields, createdAt: dateTime(), updatedAt: dateTime() }, ["id", "customerId", "name"]),
|
||
SiteListItem: open(
|
||
{
|
||
id: str(),
|
||
name: str(),
|
||
street: nstr(),
|
||
houseNumber: nstr(),
|
||
postalCode: nstr(),
|
||
city: nstr(),
|
||
status: str(),
|
||
customer: open({ id: str(), customerNumber: nstr(), companyName: nstr(), firstName: nstr(), lastName: nstr() }),
|
||
_count: obj({ workOrders: int() }),
|
||
},
|
||
["id", "name"],
|
||
),
|
||
SiteHistoryEntry: obj(
|
||
{
|
||
workOrderId: str(),
|
||
number: str(),
|
||
title: str(),
|
||
date: dateTime({ description: "Erster Arbeitsbeginn, sonst plannedStart, sonst createdAt" }),
|
||
status: ref("WorkOrderStatus"),
|
||
isEmergency: bool(),
|
||
orderType: nstr(),
|
||
team: nstr(),
|
||
workDone: arr(str()),
|
||
summary: str({ maxLength: 280 }),
|
||
materials: arr(obj({ name: str(), unit: str(), quantity: num() }, ["name", "unit", "quantity"])),
|
||
photoCount: int(),
|
||
approvedReports: arr(obj({ id: str(), type: str({ enum: ["daily", "completion"] }), reportDate: dateTime(), version: int() }, ["id", "type", "reportDate", "version"])),
|
||
signed: bool(),
|
||
followUps: arr(str()),
|
||
hasOpenFollowUp: bool(),
|
||
},
|
||
["workOrderId", "number", "title", "date", "status"],
|
||
),
|
||
|
||
// --- work orders ---
|
||
WorkOrderListItem: open(
|
||
{
|
||
id: str(),
|
||
number: str(),
|
||
title: str(),
|
||
status: ref("WorkOrderStatus"),
|
||
priority: ref("WorkOrderPriority"),
|
||
plannedStart: nDateTime(),
|
||
plannedEnd: nDateTime(),
|
||
isEmergency: bool(),
|
||
version: int(),
|
||
updatedAt: dateTime(),
|
||
customer: personRef,
|
||
site: { oneOf: [addressRef, { type: "null" }] },
|
||
team: { oneOf: [idName, { type: "null" }] },
|
||
orderType: { oneOf: [idName, { type: "null" }] },
|
||
assignees: arr(obj({ user: idName })),
|
||
},
|
||
["id", "number", "title", "status", "version"],
|
||
),
|
||
WorkOrderList: obj(
|
||
{
|
||
items: arr(ref("WorkOrderListItem")),
|
||
total: int(),
|
||
page: int(),
|
||
pageSize: int(),
|
||
groupCounts: { type: "object", description: "Anzahl je Statusgruppe (Filter ohne status/group)", propertyNames: ref("StatusGroup"), additionalProperties: int() },
|
||
},
|
||
["items", "total", "page", "pageSize", "groupCounts"],
|
||
),
|
||
WorkOrderCreate: obj(
|
||
{
|
||
...workOrderMasterFields,
|
||
status: str({ enum: ["draft", "review_required", "planned", "in_progress"], default: "draft" }),
|
||
isEmergency: bool({ default: false }),
|
||
applyTemplate: bool({ default: true, description: "Checkliste/Fotovorgaben aus der Vorlage des Auftragstyps übernehmen" }),
|
||
numberKey: str({ enum: ["work_order", "emergency"], default: "work_order" }),
|
||
materials: arr(ref("MaterialPlanInput"), { maxItems: 200 }),
|
||
checklistItems: arr(
|
||
obj({ key: str({ pattern: "^[a-z0-9_]+$", maxLength: 60 }), label: str({ minLength: 1, maxLength: 200 }), required: bool(), requiresPhoto: bool(), sortOrder: int() }, ["label"]),
|
||
{ maxItems: 200 },
|
||
),
|
||
photoRequirements: arr(obj({ key: str({ pattern: "^[a-z0-9_]+$", maxLength: 60 }), label: str({ minLength: 1, maxLength: 200 }), sortOrder: int() }, ["label"]), { maxItems: 50 }),
|
||
},
|
||
["title", "customerId"],
|
||
{ description: "`sourceImportId` wird von der API verworfen (nur Import-Service)." },
|
||
),
|
||
WorkOrderPatch: obj({ ...workOrderMasterFields, baseVersion: int({ minimum: 1, description: "Optimistische Sperre: ≠ aktuelle Version → 409" }) }, [], {
|
||
additionalProperties: false,
|
||
description: "Teilaktualisierung der Stammdaten (strict: unbekannte Felder → 422).",
|
||
}),
|
||
WorkOrder: open(
|
||
{
|
||
id: str(),
|
||
number: str(),
|
||
title: str(),
|
||
status: ref("WorkOrderStatus"),
|
||
priority: ref("WorkOrderPriority"),
|
||
version: int(),
|
||
isEmergency: bool(),
|
||
plannedStart: nDateTime(),
|
||
plannedEnd: nDateTime(),
|
||
customerId: str(),
|
||
siteId: nstr(),
|
||
contactId: nstr(),
|
||
orderTypeId: nstr(),
|
||
description: nstr(),
|
||
scope: nstr(),
|
||
createdAt: dateTime(),
|
||
updatedAt: dateTime(),
|
||
},
|
||
["id", "number", "title", "status", "version"],
|
||
),
|
||
WorkOrderDetail: {
|
||
allOf: [
|
||
ref("WorkOrder"),
|
||
obj({
|
||
customer: open({ id: str(), customerNumber: nstr(), companyName: nstr(), firstName: nstr(), lastName: nstr(), phone: nstr(), email: nstr(), street: nstr(), houseNumber: nstr(), postalCode: nstr(), city: nstr() }),
|
||
site: { oneOf: [open({ id: str(), name: str(), accessNotes: nstr(), safetyNotes: nstr() }), { type: "null" }] },
|
||
contact: { oneOf: [open({ id: str(), name: str(), phone: nstr(), mobile: nstr(), email: nstr() }), { type: "null" }] },
|
||
orderType: { oneOf: [open({ id: str(), name: str(), key: str() }), { type: "null" }] },
|
||
team: { oneOf: [idName, { type: "null" }] },
|
||
teamLead: { oneOf: [idName, { type: "null" }] },
|
||
assignees: arr(obj({ user: idName })),
|
||
}),
|
||
],
|
||
},
|
||
WorkOrderDetailResponse: obj(
|
||
{ workOrder: ref("WorkOrderDetail"), availableTransitions: arr(ref("WorkOrderStatus"), { description: "Übergänge, die der Aufrufer jetzt auslösen darf" }), completionBlockers: arr(ref("CompletionBlocker")) },
|
||
["workOrder", "availableTransitions", "completionBlockers"],
|
||
),
|
||
VersionResult: obj({ id: str(), version: int() }, ["id", "version"]),
|
||
TransitionRequest: obj({ to: ref("WorkOrderStatus"), reason: nstr({ maxLength: 2000 }), baseVersion: int({ minimum: 1 }) }, ["to"]),
|
||
TransitionResult: obj({ id: str(), status: ref("WorkOrderStatus"), from: ref("WorkOrderStatus"), version: int() }, ["id", "status", "from", "version"]),
|
||
AssignRequest: obj({ teamId: str({ maxLength: 64 }), userIds: arr(str({ maxLength: 64 }), { maxItems: 50, default: [] }), teamLeadUserId: nstr({ maxLength: 64 }), baseVersion: int({ minimum: 1 }) }, ["teamId"]),
|
||
AssignResult: obj({ id: str(), version: int(), status: ref("WorkOrderStatus") }, ["id", "version", "status"]),
|
||
MaterialPlanInput: materialPlanInput,
|
||
MaterialPlan: open({ id: str(), workOrderId: str(), name: str(), articleNumber: nstr(), plannedQuantity: num(), unit: str(), notes: nstr(), sortOrder: int() }, ["id", "workOrderId", "name", "plannedQuantity", "unit"]),
|
||
MaterialRow: obj(
|
||
{
|
||
planId: nstr({ description: "null = Mehrmaterial ohne Vorgabe" }),
|
||
name: str(),
|
||
articleNumber: nstr(),
|
||
unit: str(),
|
||
planned: { type: ["number", "null"] },
|
||
actual: { type: ["number", "null"] },
|
||
deviation: { type: ["number", "null"] },
|
||
statuses: arr(str({ enum: ["fully_used", "partially_used", "not_used", "additional"] })),
|
||
reasons: arr(str()),
|
||
notes: nstr(),
|
||
},
|
||
["planId", "name", "unit", "planned", "actual", "deviation", "statuses", "reasons", "notes"],
|
||
),
|
||
WorkOrderDocument: obj(
|
||
{ id: str(), fileName: str(), storageKey: str(), category: str({ enum: UPLOAD_CATEGORIES }), visibility: str({ enum: DOCUMENT_VISIBILITIES }) },
|
||
["id", "fileName", "storageKey", "category", "visibility"],
|
||
),
|
||
|
||
// --- imports ---
|
||
ImportCreated: obj({ id: str(), status: str({ enum: IMPORT_STATUSES }) }, ["id", "status"]),
|
||
ImportDetail: open(
|
||
{
|
||
id: str(),
|
||
status: str({ enum: IMPORT_STATUSES }),
|
||
errorMessage: nstr(),
|
||
createdAt: dateTime(),
|
||
confirmedAt: nDateTime(),
|
||
provider: nstr(),
|
||
extractionModel: nstr(),
|
||
extractionVersion: { type: ["string", "integer", "null"] },
|
||
extractedText: nstr(),
|
||
importedByName: nstr(),
|
||
document: { oneOf: [open({ id: str(), fileName: str(), mimeType: str(), fileSize: int(), visibility: str(), createdAt: dateTime() }), { type: "null" }] },
|
||
extraction: open({ siteCandidates: arr(open({ customerId: str() })) }),
|
||
corrections: { description: "Diff Extraktion ↔ bestätigte Werte: { \"<formPfad>\": { from, to } } oder null" },
|
||
createdWorkOrder: { oneOf: [obj({ id: str(), number: str(), status: ref("WorkOrderStatus") }), { type: "null" }] },
|
||
customerCandidates: arr(open({ customerId: str(), customer: open({ id: str() }) })),
|
||
siteCandidates: arr(open({ customerId: str() })),
|
||
},
|
||
["id", "status"],
|
||
),
|
||
ImportReviewForm: obj(
|
||
{
|
||
customerMode: str({ enum: ["existing", "new"] }),
|
||
customerId: str({ description: "Pflicht bei customerMode=existing" }),
|
||
customer: obj({
|
||
customerNumber: str({ maxLength: 50 }),
|
||
companyName: str({ maxLength: 200 }),
|
||
firstName: str({ maxLength: 100 }),
|
||
lastName: str({ maxLength: 100 }),
|
||
street: str({ maxLength: 200 }),
|
||
houseNumber: str({ maxLength: 20 }),
|
||
postalCode: str({ maxLength: 10 }),
|
||
city: str({ maxLength: 100 }),
|
||
country: str({ maxLength: 2, default: "DE" }),
|
||
phone: str({ maxLength: 50 }),
|
||
email: str({ maxLength: 200 }),
|
||
}),
|
||
siteMode: str({ enum: ["existing", "new", "none"] }),
|
||
siteId: str({ description: "Pflicht bei siteMode=existing" }),
|
||
site: obj({ name: str({ maxLength: 200 }), street: str({ maxLength: 200 }), houseNumber: str({ maxLength: 20 }), postalCode: str({ maxLength: 10 }), city: str({ maxLength: 100 }), country: str({ maxLength: 2 }) }),
|
||
contact: obj({ name: str({ maxLength: 200 }), phone: str({ maxLength: 50 }), email: str({ maxLength: 200 }) }),
|
||
order: obj(
|
||
{
|
||
title: str({ minLength: 1, maxLength: 200 }),
|
||
externalOrderNumber: str({ maxLength: 100 }),
|
||
offerNumber: str({ maxLength: 100 }),
|
||
description: str({ maxLength: 10_000 }),
|
||
plannedStart: str({ description: "Datum (ISO oder deutsches Format)" }),
|
||
plannedEnd: str(),
|
||
notes: str({ maxLength: 10_000 }),
|
||
},
|
||
["title"],
|
||
),
|
||
positions: arr(obj({ name: str({ minLength: 1, maxLength: 300 }), articleNumber: str({ maxLength: 100 }), quantity: { type: ["number", "string"] }, unit: str({ maxLength: 30 }), asMaterial: bool() }, ["name"]), { maxItems: 500 }),
|
||
},
|
||
["customerMode", "customer", "siteMode", "site", "contact", "order"],
|
||
{ description: "Prüfformular (src/lib/imports/review.ts#reviewFormSchema)." },
|
||
),
|
||
ImportConfirmResult: obj({ workOrderId: str(), workOrderNumber: str(), customerId: str(), siteId: nstr(), contactId: nstr() }, ["workOrderId", "workOrderNumber", "customerId", "siteId", "contactId"]),
|
||
|
||
// --- reports ---
|
||
Report: obj(
|
||
{ id: str(), type: str({ enum: ["daily", "completion"] }), status: str({ description: "z. B. draft, submitted, team_approved, approved, rejected, superseded" }), version: int(), workOrderId: str(), lineageId: str(), hasPdf: bool() },
|
||
["id", "type", "status", "version", "workOrderId", "lineageId", "hasPdf"],
|
||
),
|
||
ReportCreateRequest: obj({ reportDate: str({ pattern: "^\\d{4}-\\d{2}-\\d{2}$", description: "Nur Tagesbericht; Default heute" }), clientId: str({ maxLength: 64, description: "Idempotenzschlüssel des Geräts" }) }),
|
||
ReportCreateResponse: obj({ report: ref("Report"), created: bool() }, ["report", "created"]),
|
||
ReportResponse: obj({ report: ref("Report") }, ["report"]),
|
||
|
||
// --- sync ---
|
||
SyncRequest: obj({ deviceId: str({ maxLength: 64 }), operations: arr(ref("SyncOperation"), { minItems: 1, maxItems: 100 }) }, ["deviceId", "operations"]),
|
||
SyncOperation: obj(
|
||
{
|
||
clientOpId: str({ format: "uuid", description: "Idempotenzschlüssel je Operation (je Mandant)" }),
|
||
opType: str({ enum: SYNC_OP_TYPES }),
|
||
entityType: str({ maxLength: 40 }),
|
||
entityId: str({ maxLength: 64 }),
|
||
baseVersion: int({ minimum: 1, description: "Pflicht für work_order.transition und report.submit (WorkOrder.version)" }),
|
||
payload: {
|
||
type: "object",
|
||
additionalProperties: true,
|
||
description:
|
||
"Payload je opType: session.start → SyncPayloadSessionStart; session.pause/resume/end → SyncPayloadSessionControl; work_order.transition → SyncPayloadTransition; note.create → SyncPayloadNoteCreate; checklist.toggle → SyncPayloadChecklistToggle; material.upsert → SyncPayloadMaterialUpsert; photo.attach → SyncPayloadPhotoAttach; voice.attach → SyncPayloadVoiceAttach; emergency.create → SyncPayloadEmergencyCreate; report.save_draft → { workOrderId, reportId, texts: Partial<ReportTexts> }; report.submit → { workOrderId, reportId, aiReviewed?: boolean } (aiReviewed Pflicht für Lotse-Entwürfe, sonst rejected invalid; baseVersion Pflicht); signature.capture → noch nicht offline verfügbar (rejected invalid, nicht gespeichert).",
|
||
},
|
||
clientCreatedAt: dateTime({ description: "ISO 8601 (UTC, `Z`)" }),
|
||
},
|
||
["clientOpId", "opType", "payload", "clientCreatedAt"],
|
||
),
|
||
SyncPayloadSessionStart: obj(
|
||
{
|
||
workOrderId: opId,
|
||
clientId: uuid,
|
||
mode: str({ enum: ["travel", "work"], default: "work" }),
|
||
at: isoDateTimeOffset,
|
||
latitude: num({ minimum: -90, maximum: 90 }),
|
||
longitude: num({ minimum: -180, maximum: 180 }),
|
||
offline: bool({ default: false }),
|
||
deviceInfo: str({ maxLength: 200 }),
|
||
},
|
||
["workOrderId"],
|
||
),
|
||
SyncPayloadSessionControl: obj({ workOrderId: opId, at: isoDateTimeOffset }, ["workOrderId"]),
|
||
SyncPayloadTransition: obj({ workOrderId: opId, to: ref("WorkOrderStatus"), reason: str({ maxLength: 1000 }) }, ["workOrderId", "to"]),
|
||
SyncPayloadNoteCreate: obj({ workOrderId: opId, clientId: uuid, kind: str({ enum: NOTE_KINDS, default: "general" }), text: str({ minLength: 1, maxLength: 10_000 }) }, ["workOrderId", "text"]),
|
||
SyncPayloadChecklistToggle: obj({ workOrderId: opId, itemId: opId, checked: bool(), comment: nstr({ maxLength: 2000 }) }, ["workOrderId", "itemId", "checked"]),
|
||
SyncPayloadMaterialUpsert: obj(
|
||
{
|
||
workOrderId: opId,
|
||
clientId: uuid,
|
||
materialPlanId: nstr({ maxLength: 64 }),
|
||
name: str({ maxLength: 200 }),
|
||
articleNumber: nstr({ maxLength: 100 }),
|
||
quantity: num({ minimum: 0, maximum: 1_000_000 }),
|
||
unit: str({ minLength: 1, maxLength: 20 }),
|
||
usageStatus: str({ enum: ["fully_used", "partially_used", "not_used", "additional"] }),
|
||
deviationReason: nstr({ maxLength: 2000 }),
|
||
notes: nstr({ maxLength: 2000 }),
|
||
photoId: nstr({ maxLength: 64 }),
|
||
},
|
||
["workOrderId", "quantity", "unit", "usageStatus"],
|
||
),
|
||
SyncPayloadPhotoAttach: obj(
|
||
{
|
||
workOrderId: opId,
|
||
clientId: uuid,
|
||
documentId: str({ maxLength: 64, description: "Aus POST /uploads" }),
|
||
phase: { type: ["string", "null"], enum: ["before", "during", "after", null] },
|
||
photoRequirementId: nstr({ maxLength: 64 }),
|
||
checklistItemId: nstr({ maxLength: 64 }),
|
||
comment: nstr({ maxLength: 2000 }),
|
||
takenAt: isoDateTimeOffset,
|
||
latitude: num({ minimum: -90, maximum: 90 }),
|
||
longitude: num({ minimum: -180, maximum: 180 }),
|
||
},
|
||
["workOrderId", "documentId"],
|
||
),
|
||
SyncPayloadVoiceAttach: obj(
|
||
{ workOrderId: opId, clientId: uuid, documentId: str({ maxLength: 64 }), durationSeconds: int({ minimum: 0, maximum: 300 }), recordedAt: isoDateTimeOffset, kind: str({ enum: NOTE_KINDS }) },
|
||
["workOrderId", "documentId"],
|
||
),
|
||
SyncPayloadEmergencyCreate: obj(
|
||
{
|
||
clientIds: obj({ workOrder: uuid, session: uuid, customer: uuid, site: uuid }, ["workOrder", "session"]),
|
||
customer: {
|
||
oneOf: [
|
||
obj({ mode: str({ const: "existing" }), customerId: opId }, ["mode", "customerId"]),
|
||
obj(
|
||
{ mode: str({ const: "new" }), companyName: nstr({ maxLength: 200 }), firstName: nstr({ maxLength: 100 }), lastName: nstr({ maxLength: 100 }), phone: str({ minLength: 1, maxLength: 50 }), email: nstr({ format: "email" }), street: nstr(), houseNumber: nstr(), postalCode: nstr(), city: nstr() },
|
||
["mode", "phone"],
|
||
),
|
||
],
|
||
},
|
||
site: {
|
||
oneOf: [
|
||
obj({ mode: str({ const: "existing" }), siteId: opId }, ["mode", "siteId"]),
|
||
obj({ mode: str({ const: "new" }), name: nstr({ maxLength: 200 }), street: str({ minLength: 1, maxLength: 200 }), houseNumber: nstr(), postalCode: nstr(), city: str({ minLength: 1, maxLength: 100 }) }, ["mode", "street", "city"]),
|
||
],
|
||
},
|
||
onSiteContact: obj({ name: str({ minLength: 1, maxLength: 200 }), phone: str({ minLength: 1, maxLength: 50 }) }, ["name", "phone"]),
|
||
reason: str({ minLength: 1, maxLength: 2000 }),
|
||
startedAt: isoDateTimeOffset,
|
||
teamId: nstr({ maxLength: 64 }),
|
||
assigneeIds: arr(opId, { maxItems: 20 }),
|
||
offline: bool({ default: false }),
|
||
deviceInfo: str({ maxLength: 200 }),
|
||
},
|
||
["clientIds", "customer", "site", "onSiteContact", "reason"],
|
||
),
|
||
SyncOpResult: obj(
|
||
{
|
||
clientOpId: str({ format: "uuid" }),
|
||
status: str({ enum: ["applied", "duplicate", "conflict", "rejected"] }),
|
||
idMap: { type: "object", additionalProperties: str(), description: "Client-ID → Server-ID der erzeugten Objekte" },
|
||
entityVersion: int({ description: "Neue bzw. (bei conflict) aktuelle WorkOrder.version" }),
|
||
errorCode: str({ enum: ["not_found", "forbidden", "invalid", "conflict", "blocked", "internal"] }),
|
||
message: str({ description: "Bei blocked: JSON-kodierte CompletionBlocker[]" }),
|
||
},
|
||
["clientOpId", "status"],
|
||
),
|
||
SyncResponse: obj({ results: arr(ref("SyncOpResult")), serverTime: dateTime() }, ["results", "serverTime"]),
|
||
|
||
// --- field ---
|
||
UploadResult: obj({ documentId: str(), duplicate: bool() }, ["documentId", "duplicate"]),
|
||
FieldBundle: obj(
|
||
{
|
||
serverTime: dateTime({ description: "Als nächstes `since` verwenden" }),
|
||
since: nDateTime(),
|
||
orders: arr(
|
||
open(
|
||
{
|
||
id: str(),
|
||
number: str(),
|
||
title: str(),
|
||
status: ref("WorkOrderStatus"),
|
||
statusGroup: ref("StatusGroup"),
|
||
priority: ref("WorkOrderPriority"),
|
||
isEmergency: bool(),
|
||
plannedStart: nDateTime(),
|
||
plannedEnd: nDateTime(),
|
||
version: int(),
|
||
updatedAt: dateTime(),
|
||
externalOrderNumber: nstr(),
|
||
description: nstr(),
|
||
scope: nstr(),
|
||
technicianNotes: nstr(),
|
||
signatureRequired: bool(),
|
||
customer: { type: ["object", "null"], additionalProperties: true },
|
||
contact: { type: ["object", "null"], additionalProperties: true },
|
||
site: { type: ["object", "null"], additionalProperties: true },
|
||
orderType: { type: ["object", "null"], additionalProperties: true },
|
||
checklistItems: arr({ type: "object", additionalProperties: true }),
|
||
photoRequirements: arr({ type: "object", additionalProperties: true }),
|
||
materialPlans: arr({ type: "object", additionalProperties: true }),
|
||
materialUsages: arr({ type: "object", additionalProperties: true }),
|
||
documents: arr(open({ id: str(), title: nstr(), fileName: str(), category: str(), mimeType: str(), fileSize: int(), checksum: str(), version: int(), lineageId: nstr() })),
|
||
siteHistory: arr({ type: "object", additionalProperties: true, description: "Letzte 5 freigegebene Einsätze am Standort" }),
|
||
},
|
||
["id", "number", "status", "version"],
|
||
),
|
||
{ maxItems: 200 },
|
||
),
|
||
},
|
||
["serverTime", "since", "orders"],
|
||
),
|
||
};
|
||
|
||
const errorContent = { "application/json": { schema: ref("Error") } };
|
||
const responses: Record<string, Schema> = {
|
||
Unauthorized: { description: "`unauthorized` – kein/abgelaufenes Session-Cookie (ohne Cookie antwortet bereits der Proxy), Konto inaktiv, Sitzung invalidiert.", content: errorContent },
|
||
Forbidden: { description: "`forbidden` – Recht fehlt (DB-autoritativ), Modul deaktiviert, Passwortwechsel erforderlich oder Cross-Site-Request (CSRF).", content: errorContent },
|
||
NotFound: { description: "`not_found` – unbekannt, fremder Mandant oder außerhalb des Sichtbarkeits-Scopes.", content: errorContent },
|
||
Conflict: { description: "`conflict` – Versionskonflikt (baseVersion), Doppelbestätigung/unzulässiger Zustand oder mögliche Dubletten (details.reason = \"possible_duplicates\").", content: errorContent },
|
||
Unprocessable: { description: "`invalid` (Validierung; details = ValidationIssue[]; auch fehlerhaftes JSON/Multipart) oder `blocked` (fachlich gesperrt, z. B. details = CompletionBlocker[]).", content: errorContent },
|
||
PayloadTooLarge: { description: "`payload_too_large` – Datei/Body zu groß.", content: errorContent },
|
||
RateLimited: {
|
||
description: "`rate_limited` – Limit je Nutzer pro Minute überschritten; details.retryAfterSeconds.",
|
||
headers: { "Retry-After": { schema: int({ minimum: 1 }), description: "Sekunden" } },
|
||
content: errorContent,
|
||
},
|
||
Internal: { description: "`internal` – unerwarteter Fehler (keine internen Details).", content: errorContent },
|
||
};
|
||
|
||
const parameters: Record<string, Schema> = {
|
||
Page: query("page", int({ minimum: 1, default: 1 })),
|
||
PageSize: query("pageSize", int({ minimum: 1, maximum: 100, default: 25 })),
|
||
PageSizeHistory: query("pageSize", int({ minimum: 1, maximum: 100, default: 50 })),
|
||
Download: query("download", str({ enum: ["1"] }), "`1` → Content-Disposition attachment"),
|
||
};
|
||
const p = (name: string): Schema => ({ $ref: `#/components/parameters/${name}` });
|
||
|
||
const idParam = (what: string) => pathParam("id", `ID ${what}`);
|
||
const listOf = (item: string): Schema => obj({ data: arr(ref(item)), pagination: ref("Pagination") }, ["data", "pagination"]);
|
||
const dataOf = (item: string): Schema => obj({ data: ref(item) }, ["data"]);
|
||
|
||
// ---------- paths (relative to servers[0].url = /api/v1) ----------
|
||
|
||
const paths: Record<string, Record<string, Schema>> = {
|
||
"/customers": {
|
||
get: op({
|
||
tag: "Stammdaten",
|
||
operationId: "listCustomers",
|
||
summary: "Kunden suchen/auflisten",
|
||
module: "customers",
|
||
permissions: ["customer:read"],
|
||
parameters: [query("q", str(), "Suche in Nummer, Firma, Name, Ort, E-Mail"), query("status", str({ enum: ["active", "inactive", "provisional", "merged", "all"] }), "Default: alle außer merged"), p("Page"), p("PageSize")],
|
||
responses: { "200": jsonResponse("Seite", listOf("CustomerListItem")), ...errors("unprocessable") },
|
||
}),
|
||
post: op({
|
||
tag: "Stammdaten",
|
||
operationId: "createCustomer",
|
||
summary: "Kunden anlegen",
|
||
description: "Mögliche Dubletten ohne `acknowledgeDuplicates: true` → 409 mit details `{ reason: \"possible_duplicates\", candidates }`.",
|
||
module: "customers",
|
||
permissions: ["customer:write"],
|
||
requestBody: jsonBody(ref("CustomerCreate")),
|
||
responses: { "201": jsonResponse("Angelegt", dataOf("Customer")), ...errors("conflict", "unprocessable") },
|
||
}),
|
||
},
|
||
"/customers/{id}": {
|
||
get: op({
|
||
tag: "Stammdaten",
|
||
operationId: "getCustomer",
|
||
summary: "Kunde inkl. Ansprechpartner",
|
||
module: "customers",
|
||
permissions: ["customer:read"],
|
||
parameters: [idParam("des Kunden")],
|
||
responses: { "200": jsonResponse("Kunde", dataOf("CustomerWithContacts")), ...errors("not_found") },
|
||
}),
|
||
patch: op({
|
||
tag: "Stammdaten",
|
||
operationId: "updateCustomer",
|
||
summary: "Kunden ändern (absent = unverändert, null = leeren)",
|
||
module: "customers",
|
||
permissions: ["customer:write"],
|
||
parameters: [idParam("des Kunden")],
|
||
requestBody: jsonBody(ref("CustomerPatch")),
|
||
responses: { "200": jsonResponse("Geändert", dataOf("Customer")), ...errors("not_found", "conflict", "unprocessable") },
|
||
}),
|
||
},
|
||
"/sites": {
|
||
get: op({
|
||
tag: "Stammdaten",
|
||
operationId: "listSites",
|
||
summary: "Standorte suchen/auflisten",
|
||
module: "sites",
|
||
permissions: ["site:read"],
|
||
parameters: [query("q", str()), query("customerId", str()), query("status", str({ enum: ["active", "inactive", "provisional", "all"] })), p("Page"), p("PageSize")],
|
||
responses: { "200": jsonResponse("Seite", listOf("SiteListItem")), ...errors("unprocessable") },
|
||
}),
|
||
post: op({
|
||
tag: "Stammdaten",
|
||
operationId: "createSite",
|
||
summary: "Standort anlegen",
|
||
module: "sites",
|
||
permissions: ["site:write"],
|
||
requestBody: jsonBody(ref("SiteCreate")),
|
||
responses: { "201": jsonResponse("Angelegt", dataOf("Site")), ...errors("unprocessable") },
|
||
}),
|
||
},
|
||
"/sites/{id}/history": {
|
||
get: op({
|
||
tag: "Stammdaten",
|
||
operationId: "getSiteHistory",
|
||
summary: "Einsatzhistorie eines Standorts (neueste zuerst)",
|
||
description: "Rollen ohne `work_order:read_all` erhalten immer nur freigegebene Einsätze (unabhängig von onlyApproved). Interne Notizen sind nie enthalten.",
|
||
module: "sites",
|
||
permissions: ["site:read"],
|
||
parameters: [idParam("des Standorts"), query("onlyApproved", str({ enum: ["true", "1", "false"] })), p("Page"), p("PageSizeHistory")],
|
||
responses: {
|
||
"200": jsonResponse(
|
||
"Seite",
|
||
obj({ data: arr(ref("SiteHistoryEntry")), pagination: ref("Pagination"), meta: obj({ onlyApproved: bool() }, ["onlyApproved"]) }, ["data", "pagination", "meta"]),
|
||
),
|
||
...errors("not_found"),
|
||
},
|
||
}),
|
||
},
|
||
"/work-orders": {
|
||
get: op({
|
||
tag: "Aufträge",
|
||
operationId: "listWorkOrders",
|
||
summary: "Aufträge filtern (immer im Sichtbarkeits-Scope)",
|
||
description: "Scope: `work_order:read_all` → alle, `work_order:read_team` → eigene/Team, sonst leer. Ungültige Filterwerte werden ignoriert.",
|
||
module: "work_orders",
|
||
permissions: [],
|
||
parameters: [
|
||
query("q", str({ maxLength: 100 })),
|
||
query("status", str(), "Komma-getrennte WorkOrderStatus-Werte"),
|
||
query("group", ref("StatusGroup")),
|
||
query("preset", str({ enum: PRESETS })),
|
||
query("from", str({ format: "date" }), "YYYY-MM-DD"),
|
||
query("to", str({ format: "date" }), "YYYY-MM-DD (inkl.)"),
|
||
query("customerId", str()),
|
||
query("siteId", str()),
|
||
query("teamId", str()),
|
||
query("userId", str()),
|
||
query("orderTypeId", str()),
|
||
query("priority", ref("WorkOrderPriority")),
|
||
query("sort", str({ enum: SORT_FIELDS, default: "plannedStart" })),
|
||
query("dir", str({ enum: ["asc", "desc"], default: "asc" })),
|
||
query("page", int({ minimum: 1, maximum: 10_000, default: 1 })),
|
||
query("pageSize", int({ minimum: 1, maximum: 100, default: 25 })),
|
||
],
|
||
responses: { "200": jsonResponse("Liste", ref("WorkOrderList")), ...errors() },
|
||
}),
|
||
post: op({
|
||
tag: "Aufträge",
|
||
operationId: "createWorkOrder",
|
||
summary: "Auftrag anlegen",
|
||
description: "Recht im Service: `work_order:write` (oder `emergency:create` bei isEmergency).",
|
||
module: "work_orders",
|
||
permissions: ["work_order:write"],
|
||
requestBody: jsonBody(ref("WorkOrderCreate")),
|
||
responses: { "201": jsonResponse("Angelegt", ref("WorkOrder")), ...errors("not_found", "unprocessable") },
|
||
}),
|
||
},
|
||
"/work-orders/{id}": {
|
||
get: op({
|
||
tag: "Aufträge",
|
||
operationId: "getWorkOrder",
|
||
summary: "Auftragsdetail inkl. möglicher Übergänge und Abschluss-Blocker",
|
||
module: "work_orders",
|
||
permissions: [],
|
||
parameters: [idParam("des Auftrags")],
|
||
responses: { "200": jsonResponse("Detail", ref("WorkOrderDetailResponse")), ...errors("not_found") },
|
||
}),
|
||
patch: op({
|
||
tag: "Aufträge",
|
||
operationId: "updateWorkOrder",
|
||
summary: "Stammdaten ändern (optimistische Sperre über baseVersion)",
|
||
module: "work_orders",
|
||
permissions: ["work_order:write"],
|
||
parameters: [idParam("des Auftrags")],
|
||
requestBody: jsonBody(ref("WorkOrderPatch")),
|
||
responses: { "200": jsonResponse("Neue Version", ref("VersionResult")), ...errors("not_found", "conflict", "unprocessable") },
|
||
}),
|
||
},
|
||
"/work-orders/{id}/assign": {
|
||
post: op({
|
||
tag: "Aufträge",
|
||
operationId: "assignWorkOrder",
|
||
summary: "Team/Monteure zuweisen",
|
||
module: "work_orders",
|
||
permissions: ["work_order:assign"],
|
||
parameters: [idParam("des Auftrags")],
|
||
requestBody: jsonBody(ref("AssignRequest")),
|
||
responses: { "200": jsonResponse("Zugewiesen", ref("AssignResult")), ...errors("not_found", "conflict", "unprocessable") },
|
||
}),
|
||
},
|
||
"/work-orders/{id}/transition": {
|
||
post: op({
|
||
tag: "Aufträge",
|
||
operationId: "transitionWorkOrder",
|
||
summary: "Statusübergang",
|
||
description: "Recht hängt vom Übergang ab (src/lib/work-orders/status.ts#requiredPermission, z. B. field:execute, work_order:cancel, work_order:release_billing). 422 `blocked` mit details = CompletionBlocker[].",
|
||
module: "work_orders",
|
||
permissions: [],
|
||
parameters: [idParam("des Auftrags")],
|
||
requestBody: jsonBody(ref("TransitionRequest")),
|
||
responses: { "200": jsonResponse("Übergang ausgeführt", ref("TransitionResult")), ...errors("not_found", "conflict", "unprocessable") },
|
||
}),
|
||
},
|
||
"/work-orders/{id}/materials": {
|
||
get: op({
|
||
tag: "Aufträge",
|
||
operationId: "getWorkOrderMaterials",
|
||
summary: "Material Soll/Ist inkl. Abweichungen",
|
||
module: "work_orders",
|
||
permissions: [],
|
||
parameters: [idParam("des Auftrags")],
|
||
responses: { "200": jsonResponse("Übersicht", obj({ items: arr(ref("MaterialRow")) }, ["items"])), ...errors("not_found") },
|
||
}),
|
||
post: op({
|
||
tag: "Aufträge",
|
||
operationId: "addWorkOrderMaterialPlan",
|
||
summary: "Materialvorgabe hinzufügen",
|
||
module: "work_orders",
|
||
permissions: ["work_order:write"],
|
||
parameters: [idParam("des Auftrags")],
|
||
requestBody: jsonBody(ref("MaterialPlanInput")),
|
||
responses: { "201": jsonResponse("Angelegt", ref("MaterialPlan")), ...errors("not_found", "unprocessable") },
|
||
}),
|
||
},
|
||
"/work-orders/{id}/documents": {
|
||
post: op({
|
||
tag: "Aufträge",
|
||
operationId: "uploadWorkOrderDocument",
|
||
summary: "Dokument zum Auftrag hochladen (multipart)",
|
||
description: "Mit `Accept: text/html` (Backoffice-Formular) antwortet die Route mit 303 zurück auf den Dokumente-Tab (Fehler als Query `uploadError`). Sichtbarkeit `backoffice_only` erfordert `document:read_internal`.",
|
||
module: "work_orders",
|
||
permissions: ["document:write"],
|
||
parameters: [idParam("des Auftrags")],
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
"multipart/form-data": {
|
||
schema: obj(
|
||
{ file: str({ contentMediaType: "application/octet-stream" }), category: str({ enum: UPLOAD_CATEGORIES, default: "other" }), visibility: str({ enum: DOCUMENT_VISIBILITIES, default: "team" }), title: str() },
|
||
["file"],
|
||
),
|
||
},
|
||
},
|
||
},
|
||
responses: {
|
||
"201": jsonResponse("Gespeichert", ref("WorkOrderDocument")),
|
||
"303": { description: "Redirect (nur bei Accept: text/html)" },
|
||
...errors("not_found", "unprocessable", "payload_too_large"),
|
||
},
|
||
}),
|
||
},
|
||
"/work-orders/{id}/daily-report": {
|
||
post: op({
|
||
tag: "Berichte",
|
||
operationId: "createDailyReport",
|
||
summary: "Tagesbericht-Entwurf anlegen oder vorhandenen zurückgeben",
|
||
description: "Idempotent je (Auftrag, Tag) bzw. clientId: vorhandener Entwurf/abgelehnter Bericht → 200, neu → 201. Bereits eingereicht → 409.",
|
||
module: "reports",
|
||
permissions: ["report:write"],
|
||
parameters: [idParam("des Auftrags")],
|
||
requestBody: jsonBody(ref("ReportCreateRequest"), false),
|
||
responses: { "200": jsonResponse("Vorhanden", ref("ReportCreateResponse")), "201": jsonResponse("Angelegt", ref("ReportCreateResponse")), ...errors("not_found", "conflict", "unprocessable") },
|
||
}),
|
||
},
|
||
"/work-orders/{id}/completion-report": {
|
||
post: op({
|
||
tag: "Berichte",
|
||
operationId: "createCompletionReport",
|
||
summary: "Abschlussbericht-Entwurf anlegen oder vorhandenen zurückgeben",
|
||
description: "Offene Pflichtpunkte → 422 `blocked` mit details = CompletionBlocker[].",
|
||
module: "reports",
|
||
permissions: ["report:write"],
|
||
parameters: [idParam("des Auftrags")],
|
||
requestBody: jsonBody(obj({ clientId: str({ maxLength: 64 }) }), false),
|
||
responses: { "200": jsonResponse("Vorhanden", ref("ReportCreateResponse")), "201": jsonResponse("Angelegt", ref("ReportCreateResponse")), ...errors("not_found", "conflict", "unprocessable") },
|
||
}),
|
||
},
|
||
"/work-orders/import": {
|
||
post: op({
|
||
tag: "Import",
|
||
operationId: "createImport",
|
||
summary: "Auftragsdokument hochladen (multipart), Extraktion läuft im Hintergrund",
|
||
description: "Erlaubt: application/pdf, image/jpeg, image/png; max. 25 MB.",
|
||
module: "imports",
|
||
permissions: ["import:write"],
|
||
requestBody: { required: true, content: { "multipart/form-data": { schema: obj({ file: str({ contentMediaType: "application/octet-stream" }) }, ["file"]) } } },
|
||
responses: { "201": jsonResponse("Import angelegt", ref("ImportCreated")), ...errors("unprocessable", "payload_too_large") },
|
||
}),
|
||
},
|
||
"/imports/{id}": {
|
||
get: op({
|
||
tag: "Import",
|
||
operationId: "getImport",
|
||
summary: "Importstatus, Extraktion (mit Konfidenzen), Kandidaten",
|
||
module: "imports",
|
||
permissions: ["import:write"],
|
||
parameters: [idParam("des Imports")],
|
||
responses: { "200": jsonResponse("Import", ref("ImportDetail")), ...errors("not_found") },
|
||
}),
|
||
},
|
||
"/imports/{id}/confirm": {
|
||
post: op({
|
||
tag: "Import",
|
||
operationId: "confirmImport",
|
||
summary: "Geprüften Import bestätigen → Kunde/Standort/Kontakt/Auftrag",
|
||
description: "Nur im Status review_required; erneute Bestätigung → 409.",
|
||
module: "imports",
|
||
permissions: ["import:write", "work_order:write"],
|
||
parameters: [idParam("des Imports")],
|
||
requestBody: jsonBody(ref("ImportReviewForm")),
|
||
responses: { "200": jsonResponse("Bestätigt", ref("ImportConfirmResult")), ...errors("not_found", "conflict", "unprocessable") },
|
||
}),
|
||
},
|
||
"/reports/{id}/approve": {
|
||
post: op({
|
||
tag: "Berichte",
|
||
operationId: "approveReport",
|
||
summary: "Bericht freigeben",
|
||
description: "Teamleitung (`report:approve_team`) → team_approved; Backoffice (`report:approve`) → approved inkl. PDF-Erzeugung. Falscher Status/parallel geändert → 409.",
|
||
module: "reports",
|
||
permissions: ["report:read"],
|
||
parameters: [idParam("des Berichts")],
|
||
responses: { "200": jsonResponse("Freigegeben", ref("ReportResponse")), ...errors("not_found", "conflict") },
|
||
}),
|
||
},
|
||
"/reports/{id}/pdf": {
|
||
get: op({
|
||
tag: "Berichte",
|
||
operationId: "getReportPdf",
|
||
summary: "Unveränderliches PDF eines freigegebenen Berichts",
|
||
module: "reports",
|
||
permissions: ["report:read"],
|
||
parameters: [idParam("des Berichts"), p("Download")],
|
||
responses: { "200": { ...binaryResponse("PDF"), content: { "application/pdf": { schema: str({ contentMediaType: "application/pdf" }) } } }, ...errors("not_found") },
|
||
}),
|
||
},
|
||
"/reports/{id}/files/{documentId}": {
|
||
get: op({
|
||
tag: "Berichte",
|
||
operationId: "getReportFile",
|
||
summary: "Foto/Unterschrift/Logo aus dem Bericht-Snapshot",
|
||
module: "reports",
|
||
permissions: ["report:read"],
|
||
parameters: [idParam("des Berichts"), pathParam("documentId", "Im Snapshot referenzierte Dokument-ID"), p("Download")],
|
||
responses: { "200": binaryResponse("Datei"), ...errors("not_found") },
|
||
}),
|
||
},
|
||
"/sync": {
|
||
post: op({
|
||
tag: "Einsatz",
|
||
operationId: "sync",
|
||
summary: "Batch von Offline-/Online-Operationen anwenden",
|
||
description:
|
||
"Jede Operation wird einzeln angewendet; der HTTP-Status ist 200, das Ergebnis steht je Operation in `results`. Idempotenz über `clientOpId` (Wiederholung → `duplicate` mit gespeichertem Ergebnis). `work_order.transition` und `report.submit` verlangen `baseVersion`; Abweichung von WorkOrder.version → `conflict` (entityVersion = aktuelle Version). Rechte je opType im Service (z. B. field:execute, emergency:create).",
|
||
module: "field",
|
||
permissions: [],
|
||
requestBody: jsonBody(ref("SyncRequest")),
|
||
responses: { "200": jsonResponse("Ergebnisse je Operation", ref("SyncResponse")), ...errors("unprocessable") },
|
||
}),
|
||
},
|
||
"/uploads": {
|
||
post: op({
|
||
tag: "Einsatz",
|
||
operationId: "uploadFieldFile",
|
||
summary: "Foto/Sprachnotiz hochladen (multipart) → documentId",
|
||
description: "Idempotent über `clientId`: gleiche clientId → 200 mit derselben documentId und `duplicate: true`, sonst 201. Inhalt wird per Magic Bytes geprüft (photo → Bild, voice_note → Audio). Max. 25 MB; optionales Vorschaubild ≤ 2 MB.",
|
||
module: "field",
|
||
permissions: ["field:execute"],
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
"multipart/form-data": {
|
||
schema: obj(
|
||
{
|
||
file: str({ contentMediaType: "application/octet-stream" }),
|
||
clientId: str({ format: "uuid" }),
|
||
workOrderId: str({ maxLength: 64 }),
|
||
kind: str({ enum: ["photo", "voice_note"] }),
|
||
preview: str({ contentMediaType: "image/*", description: "Optionales Thumbnail (~400 px)" }),
|
||
},
|
||
["file", "clientId", "workOrderId", "kind"],
|
||
),
|
||
},
|
||
},
|
||
},
|
||
responses: { "200": jsonResponse("Bereits vorhanden", ref("UploadResult")), "201": jsonResponse("Gespeichert", ref("UploadResult")), ...errors("not_found", "unprocessable", "payload_too_large") },
|
||
}),
|
||
},
|
||
"/field/bundle": {
|
||
get: op({
|
||
tag: "Einsatz",
|
||
operationId: "getFieldBundle",
|
||
summary: "Offline-Pull der Aufträge im Scope (max. 200)",
|
||
module: "field",
|
||
permissions: ["field:execute"],
|
||
parameters: [query("since", dateTime(), "Nur seitdem geänderte Aufträge (serverTime der letzten Antwort)")],
|
||
responses: { "200": jsonResponse("Bundle", ref("FieldBundle")), ...errors("unprocessable") },
|
||
}),
|
||
},
|
||
"/field/documents/{id}": {
|
||
get: op({
|
||
tag: "Einsatz",
|
||
operationId: "getFieldDocument",
|
||
summary: "Dokument für die Mobile-App (Sichtbarkeit + Scope geprüft)",
|
||
description: "Nur magic-byte-verifizierte Typen (JPEG/PNG/WebP, PDF, Audio) inline, sonst Download. `Cache-Control: private, max-age=300`.",
|
||
module: "field",
|
||
permissions: ["document:read"],
|
||
parameters: [idParam("des Dokuments"), query("variant", str({ enum: ["preview"] }), "Vorschaubild statt Original")],
|
||
responses: { "200": binaryResponse("Datei"), ...errors("not_found") },
|
||
}),
|
||
},
|
||
"/openapi.json": {
|
||
get: op({
|
||
tag: "Meta",
|
||
operationId: "getOpenApi",
|
||
summary: "Dieses OpenAPI-Dokument",
|
||
description: "Für jeden angemeldeten Nutzer ohne weitere Rechteprüfung; `Cache-Control: private, max-age=300`.",
|
||
module: null,
|
||
permissions: [],
|
||
responses: { "200": jsonResponse("OpenAPI 3.1", { type: "object" }), "401": { $ref: "#/components/responses/Unauthorized" } },
|
||
}),
|
||
},
|
||
};
|
||
|
||
export const API_BASE_PATH = "/api/v1";
|
||
|
||
export const openApiDocument = {
|
||
openapi: "3.1.0",
|
||
info: {
|
||
title: "Craftvia API",
|
||
version: "1.0.0",
|
||
description:
|
||
"Versionierte JSON-API von Craftvia (Backoffice, Mobile/PWA, Integrationen). Authentifizierung per Auth.js-Session-Cookie; schreibende Methoden nur Same-Origin. Einheitliches Fehlerformat `{ error: { code, message, details? } }` mit `Cache-Control: no-store`. Rate Limit je Nutzer/Minute: Standard `API_RATE_LIMIT_PER_MINUTE` (300), `/sync`, `/uploads`, `/field/**` `API_FIELD_RATE_LIMIT_PER_MINUTE` (1200), gezählt je App-Instanz. `x-craftvia-module`/`x-craftvia-permissions` nennen den Modul- und Rechte-Gate der Route; weitere Rechte/Scopes prüfen die Services. Siehe docs/craftvia/API.md.",
|
||
},
|
||
servers: [{ url: API_BASE_PATH }],
|
||
security: [{ cookieAuth: [] }],
|
||
tags: [
|
||
{ name: "Stammdaten", description: "Kunden und Standorte" },
|
||
{ name: "Aufträge", description: "Auftragsverwaltung" },
|
||
{ name: "Import", description: "Dokumentenimport mit KI-Extraktion" },
|
||
{ name: "Berichte", description: "Tages-/Abschlussberichte" },
|
||
{ name: "Einsatz", description: "Mobile/Offline: Sync, Uploads, Bundle" },
|
||
{ name: "Meta" },
|
||
],
|
||
paths,
|
||
components: {
|
||
securitySchemes: {
|
||
cookieAuth: {
|
||
type: "apiKey",
|
||
in: "cookie",
|
||
name: "authjs.session-token",
|
||
description: "Auth.js-Session-Cookie (`authjs.session-token`, unter HTTPS `__Secure-authjs.session-token`). Rechte werden bei jedem Request aus der Datenbank gelesen.",
|
||
},
|
||
},
|
||
schemas,
|
||
responses,
|
||
parameters,
|
||
},
|
||
};
|
||
|
||
/** Documented paths with full prefix, e.g. `/api/v1/work-orders/{id}/transition`. */
|
||
export const API_ROUTES: string[] = Object.keys(paths).map((path) => `${API_BASE_PATH}${path}`);
|
||
|
||
/** Documented operations as `METHOD /api/v1/...`. */
|
||
export const API_OPERATIONS: string[] = Object.entries(paths).flatMap(([path, methods]) => Object.keys(methods).map((m) => `${m.toUpperCase()} ${API_BASE_PATH}${path}`));
|