Files
craftvia/src/lib/api/openapi.ts
T
msolarczekandClaude Opus 5 29c80a0e73 L11 Kundenversand: Bericht-PDF an Kunden senden (Service, Action, API, Berichtsseite)
- sendReportToCustomer: report:approve, Scope, nur approved mit PDF, Empfänger
  Ansprechpartner -> Kunde, Dedupe je Adresse+Version, Audit export
- Server Action + POST /api/v1/reports/{id}/send inkl. OpenAPI-Eintrag
- /reports/[id]: Abschnitt „An Kunden senden" mit bisherigen Versänden, Texte de/en

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 18:48:57 +02:00

1163 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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") },
}),
},
"/reports/{id}/send": {
post: op({
tag: "Berichte",
operationId: "sendReportToCustomer",
summary: "Freigegebenes Berichts-PDF per E-Mail an den Kunden senden",
description:
"Nur Status `approved` mit PDF (sonst 422 `blocked`, details.reason `report_not_approved` | `pdf_missing`). Empfänger: `to` oder Ansprechpartner des Auftrags → Kunde; keiner → 422 `invalid` (details.reason `recipient_missing`). Gleiche Adresse + Version → `duplicate` (keine zweite Mail). Das PDF wird als Dokument-Referenz eingestellt und beim Zustellen mandantengebunden geladen und per SHA-256 geprüft. Audit `export`.",
module: "reports",
permissions: ["report:approve"],
parameters: [idParam("des Berichts")],
requestBody: jsonBody(obj({ to: str({ format: "email", maxLength: 254 }), message: str({ maxLength: 2000 }) }), false),
responses: {
"200": jsonResponse(
"Versendet, bereits versendet oder Zustellung fehlgeschlagen",
obj(
{ status: str({ enum: ["sent", "duplicate", "failed"] }), reportId: str(), to: str({ format: "email" }), version: int(), mailLogId: str() },
["status", "reportId", "to", "version"],
),
),
"202": jsonResponse(
"In die Mail-Queue eingestellt",
obj({ status: str({ const: "queued" }), reportId: str(), to: str({ format: "email" }), version: int(), mailLogId: str() }, ["status", "reportId", "to", "version"]),
),
...errors("not_found", "unprocessable"),
},
}),
},
"/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}`));