/** * 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; // ---------- 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, required: string[] = [], extra: Schema = {}): Schema => ({ type: "object", properties, ...(required.length ? { required } : {}), ...extra, }); const open = (properties: Record, 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 = { 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 { const keys: ErrorKey[] = ["unauthorized", "forbidden", ...extra, "rate_limited", "internal"]; const out: Record = {}; 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; 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 = { 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 = { 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 = { 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 = { 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: { \"\": { 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 }; 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 = { 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 = { 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> = { "/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}`));