openapi: 3.1.0

info:
  title: repair.Dspatcha — Partner API
  version: "2"
  description: |
    REST API for external systems (ERP, POS, accounting software) to pull
    repair order and customer data from repair.Dspatcha and trigger status transitions.

    **Auth:** Every request requires an `Authorization: Bearer <key>` header.
    Keys are created by the workshop admin under **Einstellungen → API-Keys**.
    The full key is shown exactly once at creation time.

    **Tenant isolation:** The `tenant_id` is derived from the key itself.
    The `{tenant_slug}` in the URL is verified against the key's tenant — a key
    cannot be used to access a different workshop's data.

    **Scopes:** Each key carries one or more scopes. Endpoints that require a scope
    the key does not have respond with `403`.

    **Rate limiting:** Default 60 requests per minute per key.
    Exceeded requests receive `429 Too Many Requests` with a `Retry-After: 60` header.

    **Pagination:** All list endpoints are paginated. Use `page` and `per_page`
    (max 100) to page through results. Use `updated_since` for delta syncs.

    **Stable IDs:** The `id` field (UUID) is the stable, immutable identifier.
    Store this in your ERP to map records across syncs. The `short_id` is
    human-readable but not globally unique.
  contact:
    name: repair.Dspatcha Support
    url: https://repair.dspatcha.com
  license:
    name: Proprietary

servers:
  - url: https://{server_slug}.repair.dspatcha.com/api/v1/{tenant_slug}
    description: Production
    variables:
      server_slug:
        default: srv01
        description: >
          Server slug assigned to your workshop. Provided by repair.Dspatcha
          during onboarding.
      tenant_slug:
        default: meine-werkstatt
        description: URL slug of your workshop (visible in the dashboard URL).
  - url: http://localhost:4002/api/v1/{tenant_slug}
    description: Local development
    variables:
      tenant_slug:
        default: meine-werkstatt

tags:
  - name: Orders
    description: Repair orders. Read requires scope `orders:read`, write requires `orders:write`.
  - name: Branches
    description: >
      Workshop locations. Use `GET /branches` once to retrieve branch IDs,
      then pass them as `branch_id` filter on orders and status-events endpoints.
      Scope `orders:read`.
  - name: Status Catalog
    description: >
      Read-only reference data for status codes, labels and colours, including
      each status's predecessor and successor codes derived from the
      workshop's configured transitions. Use once at startup to build a local
      lookup table (or transition graph). Scope `orders:read`.
  - name: Status Events
    description: >
      Per-order status history (read, requires `order_id`) and ERP-triggered
      transitions (write). Read requires scope `orders:read`; write requires
      scope `status:write`.

security:
  - BearerToken: []

# ---------------------------------------------------------------------------
# Paths
# ---------------------------------------------------------------------------

paths:

  /orders:
    get:
      operationId: listOrders
      tags: [Orders]
      summary: List repair orders
      description: |
        Returns a paginated list of repair orders, newest first.

        For incremental sync, use `updated_since` to fetch only records changed
        since your last poll. Store the `updated_at` value of the latest record
        you received and pass it as `updated_since` on the next call.

        Branch-scoped keys (created with a specific branch) automatically restrict
        results to that branch — the `branch_id` query parameter is then ignored.
      security:
        - BearerToken: ["orders:read"]
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: status
          in: query
          description: Filter by status code (e.g. `10` = Angemeldet, `50` = In Reparatur, `90` = Abholbereit).
          schema:
            type: integer
            example: 50
        - name: updated_since
          in: query
          description: ISO 8601 datetime. Only orders updated at or after this timestamp.
          schema:
            type: string
            format: date-time
            example: "2026-06-01T00:00:00Z"
        - name: branch_id
          in: query
          description: Filter by branch UUID. Ignored for branch-scoped keys.
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: Paginated list of orders.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedOrders"
              example:
                data:
                  - id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                    short_id: "A4X7KQ"
                    branch_id: "a1b2c3d4-0000-4000-8000-111122223333"
                    status: 50
                    status_label: "In Reparatur"
                    customer_name: "Max Mustermann"
                    customer_email: "max@example.com"
                    customer_phone: "+49 170 1234567"
                    customer_address: "Musterstraße 12, 10115 Berlin"
                    item_name: "Cube Aim 27.5"
                    item_category: "bike"
                    item_brand_model: "Cube"
                    fault_description: "Schaltung springt, Bremsen quietschen"
                    comment: null
                    order_type: "Inspektion"
                    kosten_limit: 150.00
                    custom_fields:
                      seriennummer: "SN-20240512-0042"
                      versicherung: "ADAC Schutzbrief"
                    checkin_date: "2026-06-02T09:15:00Z"
                    created_at: "2026-06-01T10:30:00Z"
                    updated_at: "2026-06-03T14:00:00Z"
                total: 142
                page: 1
                per_page: 20
                has_next: true
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /orders/{order_id}:
    get:
      operationId: getOrder
      tags: [Orders]
      summary: Get a single repair order (extended detail)
      description: |
        Returns a single repair order by its UUID, including extended detail fields
        not available in the list endpoint:

        - `history` — full status record history, sorted chronologically (oldest first)
        - `messages` — all customer messages on this order, sorted chronologically
        - `change_request_amount` — cost estimate submitted with the KVA transition (null if none)
        - `confirmed_at` — timestamp of customer KVA approval (null if not yet confirmed)

        Always responds with `404` when the order is not found or belongs to a
        different tenant — never `403` — to avoid leaking whether an order exists.
      security:
        - BearerToken: ["orders:read"]
      parameters:
        - name: order_id
          in: path
          required: true
          description: UUID of the repair order (the `id` field from the list endpoint).
          schema:
            type: string
            format: uuid
      responses:
        "200":
          description: The repair order.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderDetail"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
    patch:
      operationId: patchOrder
      tags: [Orders]
      summary: Update writeable order fields
      description: |
        Updates selected fields on an order from the ERP side.

        Currently writable fields:
        - `kosten_limit` — non-binding gross cost cap (updated after ERP calculation)
        - `external_id` — this integration's own foreign key for the order (e.g. an
          EAN), scoped per API key (not per tenant — two integrations may use the
          same value for different orders). Readable back via the
          `order.external_id` field-mapping placeholder.

        All fields are optional. Only provided fields are updated (PATCH semantics).
      security:
        - BearerToken: ["orders:write"]
      parameters:
        - name: order_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OrderUpdateRequest"
            example:
              kosten_limit: 120.00
              external_id: "4006381333931"
      responses:
        "200":
          description: The updated fields, echoed back.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderUpdateResponse"
              example:
                id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                short_id: "A4X7KQ"
                kosten_limit: 120.00
                external_id: "4006381333931"
                updated_at: "2026-06-03T14:00:00Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: >
            `external_id` is already assigned to a different order for this
            integration (each API key's external_id values must be unique per order).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                detail: "external_id '4006381333931' is already assigned to a different order for this integration"
        "429": { $ref: "#/components/responses/RateLimited" }

  /orders/{order_id}/status-events:
    get:
      operationId: listStatusEvents
      tags: [Status Events]
      summary: List status events for an order (chronological audit trail)
      description: |
        Returns the full status-change history for this order, in ascending
        chronological order.

        Each event records one status transition: what the order moved from
        (`old_status`) and to (`new_status`), who triggered it, and when.
        `old_status` is `null` for the very first event of an order (no prior state).

        Combine with `updated_since` to fetch only the events for this order
        since your last check (e.g. after receiving a webhook or re-polling
        `GET /orders/{order_id}`).

        The result includes transitions triggered by any actor type:
        - `team_user` — workshop staff
        - `system` — automated transitions (e.g. booking confirmation)
        - `integration_partner` — transitions you triggered via this API
        - `import` — imported from a legacy system
      security:
        - BearerToken: ["orders:read"]
      parameters:
        - name: order_id
          in: path
          required: true
          description: UUID of the repair order.
          schema:
            type: string
            format: uuid
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/PerPage"
        - name: branch_id
          in: query
          description: >
            Extra safety check: 404 if the order does not belong to this
            branch UUID. Ignored for branch-scoped keys (their branch is
            enforced automatically). Mainly useful for multi-location ERPs
            asserting the order matches the location they expect.
          schema:
            type: string
            format: uuid
        - name: updated_since
          in: query
          description: ISO 8601 datetime. Only events at or after this timestamp.
          schema:
            type: string
            format: date-time
            example: "2026-06-01T00:00:00Z"
      responses:
        "200":
          description: Paginated list of status events for this order, oldest first.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedStatusEvents"
              example:
                data:
                  - id: "8fa23c11-0000-4000-8000-aabbccdd0001"
                    order_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                    order_short_id: "A4X7KQ"
                    old_status: 10
                    new_status: 20
                    changed_by:
                      type: integration_partner
                      name: "RADFAK ERP"
                    comment: "Gerät angenommen, Inspektion beauftragt"
                    timestamp: "2026-06-02T09:15:00Z"
                  - id: "8fa23c11-0000-4000-8000-aabbccdd0002"
                    order_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                    order_short_id: "A4X7KQ"
                    old_status: 20
                    new_status: 80
                    changed_by:
                      type: team_user
                      name: "Jana Werkstatt"
                    comment: null
                    timestamp: "2026-06-04T14:30:00Z"
                total: 2
                page: 1
                per_page: 20
                has_next: false
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }

    post:
      operationId: createStatusEvent
      tags: [Status Events]
      summary: Trigger a status transition
      description: |
        Triggers a status transition on an order from the ERP side.

        **How it works:**

        repair.Dspatcha evaluates the transition against the workshop's status flow
        configuration (same rules as manual staff transitions). Only transitions that
        have been explicitly configured as allowed for "manual" triggers are accepted.
        Attempting an unconfigured transition returns `422`.

        **Per-transition API permission:** in addition to the transition being
        configured at all, the workshop admin must explicitly allow it for API
        integrations (Einstellungen → Status-Flow → "Per API auslösbar" per
        transition). A configured-but-not-API-enabled transition returns `403`,
        distinct from a missing `status:write` scope.

        After a successful transition, repair.Dspatcha dispatches customer notifications
        exactly as it would for a manual transition — if the flow configuration has
        `notify_customer` set on this transition, the customer receives the status email.

        **Typical ERP workflow (Warenwirtschaft as primary system):**

        1. ERP polls `GET /orders?status=10` to fetch new intake orders.
        2. ERP imports them, creates internal tickets, and triggers `POST .../status-events`
           with `{ "target_status": 20 }` to mark each order as accepted (check-in).
        3. ERP manages the repair lifecycle internally and triggers further status transitions
           back to repair.Dspatcha at each milestone (e.g. status 80 "Reparatur abgeschlossen").
        4. repair.Dspatcha sends the customer the pickup notification.

        The status change acts as the "already imported" marker — orders at status 10 are
        new/unprocessed; once the ERP transitions them to the next status, they no longer
        appear in a `GET /orders?status=10` delta poll.
      security:
        - BearerToken: ["status:write"]
      parameters:
        - name: order_id
          in: path
          required: true
          description: UUID of the repair order.
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/StatusEventRequest"
            example:
              target_status: 20
              comment: "Gerät angenommen, Inspektion beauftragt"
      responses:
        "201":
          description: Transition applied successfully.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/StatusEventResponse"
              example:
                order_id: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
                previous_status: 10
                new_status: 20
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403":
          description: |
            The key is valid but either:
            - the `{tenant_slug}` in the URL does not match the key's workshop,
            - the key does not have the `status:write` scope, or
            - this specific transition exists but is not enabled for API
              integrations (workshop admin has not checked "Per API auslösbar"
              for it under Einstellungen → Status-Flow). Distinct from `422` —
              the transition itself is valid, just not permitted for this actor.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                detail: "Role 'api' is not allowed for this transition"
        "404": { $ref: "#/components/responses/NotFound" }
        "422":
          description: |
            The requested transition is not permitted. Either:
            - The transition from the current status to `target_status` is not configured
              in the workshop's status flow.
            - The order is already at `target_status`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                detail: "Transition from status 10 to 90 is not configured for trigger 'manual'"
        "429": { $ref: "#/components/responses/RateLimited" }

  /orders/{order_id}/messages:
    post:
      operationId: postPartnerMessage
      tags: [Orders]
      summary: Post a team reply to a customer message
      description: |
        Sends a reply from the workshop team to the customer's message thread.
        Requires the `customer_messaging` feature to be enabled for the tenant
        and the messaging window to be open (up to 30 days after pickup).

        Automatically marks all unread customer messages on this order as read.
        Triggers a notification email to the customer.
      security:
        - BearerToken: ["messages:write"]
      parameters:
        - name: order_id
          in: path
          required: true
          schema:
            type: string
            format: uuid
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/PostMessageRequest"
      responses:
        "201":
          description: Message created and notification dispatched.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/MessageOut"
        "403":
          description: Feature not enabled or messaging window closed.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Error" }
        "404": { $ref: "#/components/responses/NotFound" }
        "422": { $ref: "#/components/responses/UnprocessableEntity" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /branches:
    get:
      operationId: listBranches
      tags: [Branches]
      summary: List branches (locations)
      description: |
        Returns all active branches (locations) of this workshop, sorted by name.

        Use this endpoint once at ERP startup to build a local lookup table of
        branch IDs and names. Pass a branch `id` as `branch_id` on `/orders`,
        `/status-events` or other filtered endpoints to scope requests per location.

        Branch-scoped keys (created with a specific branch) return only their own
        branch — the array always contains exactly one entry in that case.
      security:
        - BearerToken: ["orders:read"]
      responses:
        "200":
          description: List of active branches, sorted by name.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/Branch"
              example:
                - id: "a1b2c3d4-0000-4000-8000-111122223333"
                  slug: "mitte"
                  name: "Filiale Berlin-Mitte"
                  address: "Unter den Linden 1, 10117 Berlin"
                  phone: "+49 30 12345678"
                  email: "mitte@werkstatt.de"
                  is_default: true
                - id: "b2c3d4e5-0000-4000-8000-222233334444"
                  slug: "prenzlauer-berg"
                  name: "Filiale Prenzlauer Berg"
                  address: "Kastanienallee 42, 10435 Berlin"
                  phone: null
                  email: null
                  is_default: false
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /status-definitions:
    get:
      operationId: listStatusDefinitions
      tags: [Status Catalog]
      summary: List status definitions
      description: |
        Returns all active status codes for this workshop, sorted by `sort_order`.

        Fetch this once at ERP startup to build a local lookup table for status
        labels and colours. The result is stable — workshops rarely change their
        status definitions, but you may re-fetch periodically to pick up changes.

        Each status also carries `predecessor_codes`/`successor_codes`, derived
        from the workshop's configured transitions (all trigger types — manual,
        customer and system) — use these to build a transition graph without a
        separate request.
      security:
        - BearerToken: ["orders:read"]
      responses:
        "200":
          description: List of status definitions, ordered by sort_order.
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: "#/components/schemas/StatusDefinition"
              example:
                - code: 10
                  label: "Angemeldet"
                  color: "#9E9EA6"
                  is_terminal: false
                  sort_order: 10
                  predecessor_codes: []
                  successor_codes: [11, 20, 99]
                - code: 90
                  label: "Abholbereit"
                  color: "#34C759"
                  is_terminal: false
                  sort_order: 90
                  predecessor_codes: [80]
                  successor_codes: [91]
                - code: 91
                  label: "Abgeholt"
                  color: "#636366"
                  is_terminal: true
                  sort_order: 91
                  predecessor_codes: [90]
                  successor_codes: []
        "401": { $ref: "#/components/responses/Unauthorized" }
        "403": { $ref: "#/components/responses/Forbidden" }
        "429": { $ref: "#/components/responses/RateLimited" }

# ---------------------------------------------------------------------------
# Components
# ---------------------------------------------------------------------------

components:

  securitySchemes:
    BearerToken:
      type: http
      scheme: bearer
      description: |
        API key in the format `dsp_<16-hex>.<32-hex>`.
        Created by the workshop admin under **Einstellungen → API-Keys**.
        Send in the `Authorization` header:
        ```
        Authorization: Bearer dsp_a1b2c3d4e5f6a7b8.0011223344556677889900aabbccddeeff
        ```

  parameters:
    Page:
      name: page
      in: query
      description: Page number (1-based).
      schema:
        type: integer
        minimum: 1
        default: 1
    PerPage:
      name: per_page
      in: query
      description: Items per page. Maximum 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  schemas:

    Order:
      type: object
      required: [id, short_id, status, custom_fields, created_at, updated_at]
      properties:
        id:
          type: string
          format: uuid
          description: Stable UUID. Use this as the primary key in your ERP.
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        short_id:
          type: string
          description: Human-readable order identifier (unique per workshop).
          example: "A4X7KQ"
        branch_id:
          type: string
          format: uuid
          nullable: true
          description: Branch this order belongs to. Useful for multi-location ERP setups.
          example: "a1b2c3d4-0000-4000-8000-111122223333"
        status:
          type: integer
          description: |
            Numeric status code. Standard codes:

            | Code | Bedeutung (de) |
            |------|---------------|
            | 10 | Angemeldet |
            | 11 | Kundendaten unvollständig |
            | 20 | Angenommen / Check-in |
            | 50 | In Reparatur |
            | 70 | Warte auf Teile |
            | 80 | Reparatur abgeschlossen |
            | 90 | Abholbereit |
            | 91 | Abgeholt |
            | 99 | Storniert |

            Workshops may define custom codes. Always treat `status_label` as
            the canonical human-readable description.
          example: 50
        status_label:
          type: string
          nullable: true
          description: Human-readable status label in the workshop's configured language.
          example: "In Reparatur"
        customer_name:
          type: string
          nullable: true
          example: "Max Mustermann"
        customer_email:
          type: string
          nullable: true
          example: "max@example.com"
        customer_phone:
          type: string
          nullable: true
          example: "+49 170 1234567"
        customer_address:
          type: string
          nullable: true
          description: Customer's postal address, as entered in the intake form.
          example: "Musterstraße 12, 10115 Berlin"
        item_name:
          type: string
          nullable: true
          description: Name or model of the item to be repaired.
          example: "Cube Aim 27.5"
        item_category:
          type: string
          nullable: true
          description: Item category slug (e.g. `bike`, `phone`, `appliance`).
          example: "bike"
        item_brand_model:
          type: string
          nullable: true
          example: "Cube"
        fault_description:
          type: string
          nullable: true
          description: Customer-supplied description of the fault.
          example: "Schaltung springt, Bremsen quietschen"
        comment:
          type: string
          nullable: true
          description: Additional internal comment added during order intake or by staff.
          example: "Kunde kommt erst nächste Woche abholen"
        order_type:
          type: string
          nullable: true
          description: |
            Order type label as configured by the workshop (e.g. "Inspektion",
            "Reifenwechsel", "Komplettservice"). Maps to the workshop's order type
            configuration.
          example: "Inspektion"
        kosten_limit:
          type: number
          format: float
          nullable: true
          description: |
            Non-binding gross cost estimate cap set by the customer at registration.
            This is an indicative ceiling, not a quote or invoice amount.
            repair.Dspatcha is a pre-system (Vorsystem) — no invoices are issued here.
          example: 150.00
        custom_fields:
          type: object
          description: |
            Tenant-specific fields collected via the intake form. Keys are the
            field slugs as configured in the workshop's form builder; values are
            the customer-supplied answers. Always present — empty object `{}` when
            no custom fields are configured or filled.
          additionalProperties: true
          example:
            seriennummer: "SN-20240512-0042"
            versicherung: "ADAC Schutzbrief"
        checkin_date:
          type: string
          format: date-time
          nullable: true
          description: |
            Timestamp when the device was physically checked in by workshop staff
            (status transition to 20). Null if check-in has not yet occurred.
          example: "2026-06-02T09:15:00Z"
        created_at:
          type: string
          format: date-time
          description: When the order was created (UTC).
          example: "2026-06-01T10:30:00Z"
        updated_at:
          type: string
          format: date-time
          description: When the order was last modified (UTC). Use for delta sync.
          example: "2026-06-03T14:00:00Z"

    PaginatedOrders:
      type: object
      required: [data, total, page, per_page, has_next]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Order"
        total:
          type: integer
          description: Total number of records matching the current filter.
          example: 142
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next:
          type: boolean
          description: Whether another page exists after this one.
          example: true

    OrderUpdateRequest:
      type: object
      properties:
        kosten_limit:
          type: number
          format: float
          nullable: true
          description: Non-binding gross cost cap. Set after ERP-side cost calculation.
          example: 120.00
        external_id:
          type: string
          minLength: 1
          maxLength: 255
          nullable: true
          description: >
            This integration's own foreign key for the order (e.g. an EAN).
            Scoped per API key, not per tenant.
          example: "4006381333931"

    OrderUpdateResponse:
      type: object
      required: [id, short_id, updated_at]
      properties:
        id:
          type: string
          format: uuid
        short_id:
          type: string
        kosten_limit:
          type: number
          format: float
          nullable: true
        external_id:
          type: string
          nullable: true
        updated_at:
          type: string
          format: date-time
          nullable: true

    PostMessageRequest:
      type: object
      required: [body]
      properties:
        body:
          type: string
          minLength: 1
          maxLength: 2000
          description: Reply text to send to the customer.
          example: "Ihr Fahrrad ist fertig repariert und kann abgeholt werden."

    MessageOut:
      type: object
      required: [id, sender_type, body, created_at]
      properties:
        id:
          type: string
          format: uuid
        sender_type:
          type: string
          example: "team"
        sender_name:
          type: string
          nullable: true
          description: Display name of the sender (API key label).
          example: "ERP Warenwirtschaft"
        body:
          type: string
          example: "Ihr Fahrrad ist fertig repariert und kann abgeholt werden."
        created_at:
          type: string
          format: date-time

    StatusRecord:
      type: object
      required: [id, status, event_type, changed_by_type, created_at]
      properties:
        id:
          type: string
          format: uuid
        status:
          type: integer
          description: Status code after this event.
          example: 50
        event_type:
          type: string
          description: Type of event (e.g. `status_change`).
          example: "status_change"
        comment:
          type: string
          nullable: true
          description: Optional comment attached to this status change.
        changed_by_type:
          type: string
          description: Who triggered the change — `staff`, `customer`, `system`, or `integration_partner`.
          example: "staff"
        changed_by_name:
          type: string
          nullable: true
          description: Display name of the actor who triggered the change.
          example: "Anna Schmidt"
        created_at:
          type: string
          format: date-time
          description: When this status record was created (UTC).

    CustomerMessage:
      type: object
      required: [id, sender_type, body, created_at]
      properties:
        id:
          type: string
          format: uuid
        sender_type:
          type: string
          description: Who sent the message — `customer` or `team`.
          example: "customer"
        sender_name:
          type: string
          nullable: true
          description: Display name of the sender.
          example: "Max Mustermann"
        body:
          type: string
          description: Message text.
          example: "Wann ist mein Fahrrad fertig?"
        read_at:
          type: string
          format: date-time
          nullable: true
          description: |
            Timestamp when this message was read by the workshop team.
            Only set for messages where `sender_type` is `customer`.
            `null` means the message has not been read yet.
        created_at:
          type: string
          format: date-time
          description: When the message was sent (UTC).

    OrderDetail:
      allOf:
        - $ref: "#/components/schemas/Order"
        - type: object
          required: [history, messages]
          properties:
            change_request_amount:
              type: number
              format: float
              nullable: true
              description: |
                Cost estimate submitted with a KVA (Kostenvoranschlag) status transition.
                Null if no estimate has been requested. Advisory only — not an invoice.
              example: 89.50
            confirmed_at:
              type: string
              format: date-time
              nullable: true
              description: |
                Timestamp when the customer confirmed the cost estimate.
                Null if no confirmation has occurred.
            history:
              type: array
              description: Full status history, sorted chronologically (oldest first).
              items:
                $ref: "#/components/schemas/StatusRecord"
            messages:
              type: array
              description: |
                All customer messages on this order, sorted chronologically (oldest first).
                Includes messages from both the customer (`sender_type: customer`) and the
                workshop team (`sender_type: team`).
              items:
                $ref: "#/components/schemas/CustomerMessage"

    StatusEventRequest:
      type: object
      required: [target_status]
      properties:
        target_status:
          type: integer
          description: |
            The status code to transition to. Must be a valid configured transition
            from the order's current status (trigger `manual`).
          example: 20
        comment:
          type: string
          nullable: true
          description: Optional comment attached to the status record (visible in the audit trail).
          example: "Gerät angenommen, Inspektion beauftragt"
        cost_estimate:
          type: number
          format: decimal
          nullable: true
          description: |
            Estimated total cost (gross, advisory) for transitions that require an
            amount (`requires_amount: true` in the status flow config, e.g. 30 → 40).
            Must be greater than 0 when provided. Stored as `change_request_amount`
            on the order and included in the customer notification email.
          example: 89.50

    StatusEventResponse:
      type: object
      required: [order_id, previous_status, new_status]
      properties:
        order_id:
          type: string
          format: uuid
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        previous_status:
          type: integer
          example: 10
        new_status:
          type: integer
          example: 20

    Branch:
      type: object
      required: [id, slug, name, is_default]
      properties:
        id:
          type: string
          format: uuid
          description: Stable UUID. Use as `branch_id` in filter parameters.
          example: "a1b2c3d4-0000-4000-8000-111122223333"
        slug:
          type: string
          description: URL slug of the branch.
          example: "mitte"
        name:
          type: string
          description: Display name of the branch.
          example: "Filiale Berlin-Mitte"
        address:
          type: string
          nullable: true
          description: Postal address of the branch.
          example: "Unter den Linden 1, 10117 Berlin"
        phone:
          type: string
          nullable: true
          example: "+49 30 12345678"
        email:
          type: string
          nullable: true
          example: "mitte@werkstatt.de"
        is_default:
          type: boolean
          description: True for the primary branch of the workshop.
          example: true

    StatusDefinition:
      type: object
      required: [code, label, color, is_terminal, sort_order, predecessor_codes, successor_codes]
      properties:
        code:
          type: integer
          description: Numeric status code.
          example: 90
        label:
          type: string
          description: Human-readable label in the workshop's default language (de → en fallback).
          example: "Abholbereit"
        color:
          type: string
          description: Hex colour code for UI display (e.g. status badges).
          example: "#34C759"
        is_terminal:
          type: boolean
          description: >
            True if this status represents a final state (e.g. "Abgeholt", "Storniert").
            Terminal orders are typically excluded from active workflows.
          example: false
        sort_order:
          type: integer
          description: Display order relative to other statuses.
          example: 90
        predecessor_codes:
          type: array
          items:
            type: integer
          description: >
            Status codes from which this status is reachable, derived from the
            workshop's configured transitions (all trigger types). Empty for a
            status nothing transitions into (e.g. the initial status).
          example: [80]
        successor_codes:
          type: array
          items:
            type: integer
          description: >
            Status codes this status can transition into, derived from the
            workshop's configured transitions (all trigger types). Empty for a
            terminal status.
          example: [91, 99]

    ChangedBy:
      type: object
      required: [type]
      properties:
        type:
          type: string
          description: >
            Actor type that triggered the transition.
            One of: `team_user`, `system`, `integration_partner`, `import`.
          example: integration_partner
        name:
          type: string
          nullable: true
          description: Display name of the actor (API key label, staff name, etc.).
          example: "RADFAK ERP"

    StatusEvent:
      type: object
      required: [id, order_id, order_short_id, old_status, new_status, changed_by, timestamp]
      properties:
        id:
          type: string
          format: uuid
          description: Unique ID of this status event record.
          example: "8fa23c11-0000-4000-8000-aabbccdd0001"
        order_id:
          type: string
          format: uuid
          description: UUID of the affected repair order.
          example: "3fa85f64-5717-4562-b3fc-2c963f66afa6"
        order_short_id:
          type: string
          description: Human-readable order identifier for display.
          example: "A4X7KQ"
        old_status:
          type: integer
          nullable: true
          description: Status code before the transition. Null for the very first event of an order.
          example: 10
        new_status:
          type: integer
          description: Status code after the transition.
          example: 20
        changed_by:
          $ref: "#/components/schemas/ChangedBy"
        comment:
          type: string
          nullable: true
          description: Optional comment attached to this transition.
          example: "Gerät angenommen, Inspektion beauftragt"
        timestamp:
          type: string
          format: date-time
          description: When the transition occurred (UTC). Use this as the cursor for polling.
          example: "2026-06-02T09:15:00Z"

    PaginatedStatusEvents:
      type: object
      required: [data, total, page, per_page, has_next]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/StatusEvent"
        total:
          type: integer
          description: Total number of events matching the current filter.
          example: 2
        page:
          type: integer
          example: 1
        per_page:
          type: integer
          example: 20
        has_next:
          type: boolean
          example: false

    Error:
      type: object
      required: [detail]
      properties:
        detail:
          type: string
          description: Human-readable error message.
          example: "Missing scope: orders:read"

  responses:
    Unauthorized:
      description: |
        Missing, invalid, revoked, or expired API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            detail: "Invalid or missing bearer token"

    Forbidden:
      description: |
        The key is valid but either:
        - the `{tenant_slug}` in the URL does not match the key's workshop, or
        - the key does not have the required scope.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            detail: "Missing scope: orders:read"

    NotFound:
      description: |
        The requested resource was not found, or belongs to a different workshop.
        Always `404` — never `403` — to avoid leaking whether a record exists.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            detail: "Order not found"

    RateLimited:
      description: |
        Rate limit exceeded. Default: 60 requests per minute per key.
        Retry after the number of seconds indicated in the `Retry-After` header.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds to wait before retrying.
          example: 60
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          example:
            detail: "Rate limit exceeded"
