openapi: 3.1.0

info:
  title: Calibrate Upload Portal — Shop Integration API
  description: |
    Scoped subset of the Upload Portal API intended for e-commerce shop integrations
    (Shopify, WooCommerce, custom carts, etc.). Covers the full shop workflow:

    1. Authenticate the shop with an app token (`clientId` + `clientSecret`).
    2. Create an order from the shop's cart payload.
    3. Hand the customer a portal share link to upload their artwork.
    4. React to the outbound `order.confirmed` webhook once the customer submits.
    5. Download preflighted resources to forward to the print workflow.

    For the full admin API surface (tenants, users, templates, portal endpoints),
    see `backend/docs/api/openApi.json`.
  version: "1.0.0"
  contact:
    name: Calibrate
    url: https://calibrate.at

servers:
  - url: https://api.example.com
    description: Production (replace with the tenant's Encore Cloud endpoint)
  - url: http://localhost:4000
    description: Local development

tags:
  - name: Auth
    description: Token issuance for shop (app) credentials.
  - name: Orders
    description: Order lifecycle — create, fetch, list.
  - name: Share Links
    description: Customer handoff links valid 7 days.
  - name: Resources
    description: Download preflighted artwork.

security:
  - bearerAuth: []

paths:

  /auth/token:
    post:
      tags: [Auth]
      summary: Exchange shop app credentials for a Bearer JWT
      description: |
        Shops authenticate with a long-lived `clientId` + `clientSecret` pair issued
        by the tenant. The returned token is prefixed with `app_` and is valid for
        **24 hours**. Cache the token until `expireAt`, refresh on HTTP 401.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/AppTokenRequest"
            examples:
              default:
                value:
                  clientId: "YOUR_CLIENT_ID"
                  clientSecret: "YOUR_CLIENT_SECRET"
      responses:
        "200":
          description: Bearer JWT issued.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/JWTCreateResponse"
              examples:
                default:
                  value:
                    token: "app_eyJhbGciOi..."
                    type: "Bearer"
                    expireAt: 1715200000
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "400":
          $ref: "#/components/responses/InvalidArgument"

  /orders:
    post:
      tags: [Orders]
      summary: Create an order
      description: |
        Creates an order with the IWS Generic Order structure (products → parts →
        pageRanges → variants). The response includes the full created hierarchy
        and — since 12-3 — the IWS-compliant `standardOrder` export.

        A share link is **not** created automatically. Call `POST /orders/:id/share`
        afterwards to generate the customer-facing portal URL.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateOrderRequest"
      responses:
        "200":
          description: Order created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResponse"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "400":
          $ref: "#/components/responses/InvalidArgument"

    get:
      tags: [Orders]
      summary: List orders (paginated)
      parameters:
        - name: page
          in: query
          schema: { type: integer, default: 1, minimum: 1 }
        - name: pageSize
          in: query
          schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
      responses:
        "200":
          description: Paginated list.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaginatedOrderResponse"
        "401":
          $ref: "#/components/responses/Unauthenticated"

  /orders/simple:
    post:
      tags: [Orders]
      summary: Create an order (simplified, template-free)
      description: |
        Integration-friendly alternative to `POST /orders`. You send a flat
        product description (`flat`, `folded`, or saddle-stitch `booklet`) and the
        backend expands it into the full XJDF-based hierarchy — no nested
        products/parts/pageRanges/variants to build.

        Set `options.createShareLink: true` to get a ready-to-use portal URL
        (`shareUrl`) back in the response.

        Out of scope (use `POST /orders`): perfect-binding/hardcover, multi-variant
        / language splits, complex finishing.

        Copy-pasteable example payloads:
        `_ai-context/docs/examples/simple-order/`.
      security:
        - basicAuth: []
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SimpleOrderRequest"
      responses:
        "200":
          description: Order created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SimpleOrderResponse"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "400":
          $ref: "#/components/responses/InvalidArgument"

  /orders/{id}:
    get:
      tags: [Orders]
      summary: Get order with full hierarchy and status
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: Order with products, parts, page ranges, variants, resources.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OrderResponse"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "403":
          $ref: "#/components/responses/PermissionDenied"
        "404":
          $ref: "#/components/responses/NotFound"

  /orders/{id}/share:
    post:
      tags: [Share Links]
      summary: Create a 7-day share link for the customer
      description: |
        Generates a `sharedKey` the customer uses to access the portal upload flow.
        Construct the URL as `${PORTAL_URL}/s/${sharedKey}`. Any previously active
        share is revoked first.
      parameters:
        - $ref: "#/components/parameters/OrderId"
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                metadata:
                  type: object
                  properties:
                    externalUserId: { type: string }
                    externalUserEmail: { type: string, format: email }
      responses:
        "200":
          description: Share link created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShareLinkResponse"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"

    delete:
      tags: [Share Links]
      summary: Revoke all shares for an order
      parameters:
        - $ref: "#/components/parameters/OrderId"
      responses:
        "200":
          description: Revoked.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"

  /products/{id}/resources:
    get:
      tags: [Resources]
      summary: List a product's resources (with presigned download URLs)
      description: |
        Returns the resources attached to a product — in most shop flows the
        merged, preflighted output you forward to production. Download URLs are
        included by default (`withDownloadUrls=true`); each is a presigned S3 URL
        valid for **7 days**. Use `type` to filter (e.g. only the preflighted
        output).
      parameters:
        - $ref: "#/components/parameters/ProductId"
        - name: withDownloadUrls
          in: query
          description: Include presigned download URLs on each resource.
          schema: { type: boolean, default: true }
        - name: type
          in: query
          description: |
            Filter by resource type. Examples: `Original`, `Splitted`, `Merged`,
            `Preflighted`. Omit to return all.
          schema: { type: string }
      responses:
        "200":
          description: The product's resources, each with an optional download URL.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ProductResourcesResponse"
        "401":
          $ref: "#/components/responses/Unauthenticated"
        "404":
          $ref: "#/components/responses/NotFound"

webhooks:
  orderConfirmed:
    post:
      summary: Order confirmed by customer in the portal
      description: |
        Delivered to the URL configured in the tenant's
        `general.integrations.webhook.orderCompletionUrl` setting when a customer
        confirms the order in the portal.

        **Delivery:** Up to 4 attempts with exponential backoff at 0s, 1s, 3s, 9s.
        Any HTTP 2xx response counts as success.

        **Signature verification (required):** Every request carries
        `X-Webhook-Signature: <hex>` where `<hex>` is `HMAC-SHA256(rawBody, secret)`.
        **Verify against the raw request body before JSON-parsing** — re-serialized
        JSON will not match.

        Node.js:
        ```js
        const sig = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
        if (sig !== req.headers["x-webhook-signature"]) return res.status(401).end();
        ```

        Python:
        ```python
        sig = hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest()
        if not hmac.compare_digest(sig, request.headers["X-Webhook-Signature"]):
            abort(401)
        ```
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/WebhookPayload"
      responses:
        "200":
          description: Shop acknowledged. Any 2xx counts as success.

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: |
        `Authorization: Bearer app_…` — issued by `POST /auth/token`.
    basicAuth:
      type: http
      scheme: basic
      description: |
        **Static API key.** HTTP Basic with your application `clientId` as the
        username and `clientSecret` as the password — no `POST /auth/token`
        exchange and no expiry. Equivalent identity to a Bearer `app_…` token.
        Always send over HTTPS.

  parameters:
    OrderId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: Order UUID.
    ProductId:
      name: id
      in: path
      required: true
      schema: { type: string, format: uuid }
      description: Product UUID (from `order.products[].id`).

  responses:
    Unauthenticated:
      description: Missing or invalid Bearer token.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
          example: { code: "unauthenticated", message: "A valid JWT must be used." }
    PermissionDenied:
      description: Token is valid but targets another tenant.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
          example: { code: "permission_denied", message: "Tenant mismatch" }
    NotFound:
      description: Resource does not exist (or is not visible to this tenant).
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
          example: { code: "not_found", message: "Order not found." }
    InvalidArgument:
      description: Request body failed validation.
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ApiError" }
          example: { code: "invalid_argument", message: "Invalid UUID format" }

  schemas:

    ApiError:
      type: object
      required: [code, message]
      properties:
        code:
          type: string
          enum: [unauthenticated, permission_denied, not_found, invalid_argument, internal]
        message: { type: string }

    AppTokenRequest:
      type: object
      required: [clientId, clientSecret]
      properties:
        clientId: { type: string }
        clientSecret: { type: string, format: password }

    JWTCreateResponse:
      type: object
      required: [token, expireAt]
      properties:
        token:
          type: string
          description: JWT, prefixed with `app_`.
        type:
          type: string
          enum: [Bearer]
        expireAt:
          type: integer
          format: int64
          description: Unix timestamp (seconds) at which the token expires.

    CreateOrderRequest:
      type: object
      required: [order]
      properties:
        order:
          $ref: "#/components/schemas/GenericOrder"

    SimpleOrderRequest:
      description: |
        Flat, template-free order contract. `order` holds the order content,
        `options` holds portal-side switches. See the per-type field rules on
        `SimpleOrderItem`.
      type: object
      required: [order]
      properties:
        order:
          type: object
          required: [name, items]
          properties:
            name: { type: string }
            externalOrderId:
              type: string
              description: The shop's own order reference.
            customer: { $ref: "#/components/schemas/SimpleCustomer" }
            items:
              type: array
              minItems: 1
              items: { $ref: "#/components/schemas/SimpleOrderItem" }
        options:
          type: object
          properties:
            createShareLink:
              type: boolean
              description: Create a share link and return its portal URL.

    SimpleCustomer:
      type: object
      required: [name]
      properties:
        name: { type: string }
        email: { type: string, format: email }
        externalId: { type: string }

    SimpleMaterial:
      type: object
      required: [weight]
      properties:
        weight: { type: number, description: "Substrate weight in g/m²." }
        type: { type: string, description: "e.g. coated, uncoated, matte, glossy." }

    SimplePart:
      description: A cover/body part of a booklet. Colours are always defined per part.
      type: object
      required: [colors]
      properties:
        name: { type: string, description: "Shown in the portal UI (e.g. Cover, Body)." }
        colors: { type: string, description: 'Front/back ink count, e.g. "4/4".' }
        material: { $ref: "#/components/schemas/SimpleMaterial" }

    SimpleOrderItem:
      description: |
        One product line. Required fields depend on `type`:
        - `flat`: format|width/height, pages, colors
        - `folded`: as flat + fold; closedWidth/closedHeight for the closed size
        - `booklet`: bodyPages (multiple of 4), body (with colors); cover (object)
          or `cover: null` for a self-cover booklet
      type: object
      required: [type, name, quantity]
      properties:
        type:
          type: string
          enum: [flat, folded, booklet]
          description: '"book" is reserved for a later phase.'
        name: { type: string }
        quantity: { type: integer, minimum: 1 }
        format:
          type: string
          enum: [A6, A5, A4, A3, DL]
          description: Named format — or use width/height (mm).
        width: { type: number, description: "mm (alternative to format). For folded: the OPEN size." }
        height: { type: number, description: "mm." }
        pages: { type: integer, description: "flat/folded only." }
        colors: { type: string, description: 'flat/folded only, e.g. "4/4", "4/0", "1/0".' }
        bleed: { type: number, description: "Bleed in mm (the default unit)." }
        material: { $ref: "#/components/schemas/SimpleMaterial" }
        fold: { type: string, description: "folded only, e.g. half, letter, z, gate." }
        closedWidth: { type: number, description: "folded: closed size in mm." }
        closedHeight: { type: number, description: "folded: closed size in mm." }
        bodyPages: { type: integer, description: "booklet: multiple of 4 (saddle-stitch)." }
        coverPages: { type: integer, description: "booklet: default 4." }
        binding:
          type: string
          enum: [saddle]
          description: Phase 1 supports saddle-stitch only.
        cover:
          nullable: true
          description: "Cover part — or null for a self-cover booklet."
          allOf:
            - $ref: "#/components/schemas/SimplePart"
        body: { $ref: "#/components/schemas/SimplePart" }

    SimpleProductSummary:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        type: { type: string }
        displayType: { type: string }

    SimpleOrderResponse:
      type: object
      properties:
        id: { type: string, format: uuid }
        orderNumber: { type: integer, nullable: true }
        status: { $ref: "#/components/schemas/Status" }
        milestone: { $ref: "#/components/schemas/Milestone" }
        shareUrl:
          type: string
          description: Present when options.createShareLink was true.
        products:
          type: array
          items: { $ref: "#/components/schemas/SimpleProductSummary" }

    GenericOrder:
      description: |
        IWS Generic Order structure. Authoritative schema:
        `backend/src/schemas/generic/1.0/generic_order_schema_input.json`.
        The schema's `required` list is NOT fully enforced at runtime — this
        spec marks only server-enforced fields as `required`. Schema-required
        but optional-at-runtime fields (`customerId`, `jobCustomer`,
        `jobContacts`, `orderDate`) are listed as properties with documentation
        explaining what they populate. Omit at your own risk: downstream IWS
        export and admin-dashboard UX suffer.
      type: object
      required: [name, products]
      properties:
        name: { type: string }
        orderDate:
          type: string
          format: date
          description: ISO-8601. Shown on admin dashboard and used for delivery filters.
        deliveryDate: { type: string, format: date, nullable: true }
        externalOrderId:
          type: string
          description: The shop's own order reference (shows up on the admin dashboard).
        comment: { type: string, nullable: true }
        customerId:
          type: string
          description: |
            IWS downstream customer ID (opaque). Server does not validate format or enforce presence.
            Include when your shop has a stable customer UUID/ID to help IWS consumers correlate orders.
        jobCustomer:
          $ref: "#/components/schemas/JobCustomer"
        jobContacts:
          type: array
          items: { $ref: "#/components/schemas/JobContact" }
        products:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/GenericProduct" }

    JobCustomer:
      type: object
      description: Customer billing/identity. Populates admin dashboard.
      properties:
        name: { type: string }
        externalId: { type: string }
        address:
          type: object
          description: Free-form; server stores but does not validate.
          additionalProperties: true

    JobContact:
      type: object
      properties:
        contactType:
          type: string
          description: e.g. "Buyer", "Delivery", "Billing".
        name: { type: string }
        email: { type: string, format: email }
        phone: { type: string }
        address:
          type: object
          additionalProperties: true

    GenericProduct:
      type: object
      required: [name, amount, parts]
      properties:
        name: { type: string }
        type:
          type: string
          description: |
            IWS `ProductType`. Examples: `Booklet`, `FlatWork`, `Brochure`,
            `Postcard`, `BusinessCard`, `Poster`, `Envelope`. Full enum in
            `backend/src/types/generic-order.ts:10-50`.
        amount:
          type: integer
          minimum: 1
          description: Total copies to produce. Must equal sum of variant amounts across the product's page ranges.
        orderDate: { type: string, format: date }
        intent: { $ref: "#/components/schemas/ProductIntent" }
        parts:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/GenericPart" }

    ProductIntent:
      type: object
      properties:
        bindingIntent:
          type: object
          properties:
            bindingType:
              type: string
              description: "`SaddleStitch`, `SoftCover`, `HardCover`, `Sewn`, `CoilBinding`, `CombBinding`, `WireComb`, `None`."
            bindingSide:
              type: string
              enum: [Left, Right, Top, Bottom]
            bindingOrder:
              type: string
              enum: [Collecting, Gathering, None]
            childRefs:
              type: array
              items: { type: string }

    GenericPart:
      type: object
      required: [name, pageRanges]
      properties:
        name: { type: string }
        type:
          type: string
          description: IWS part type (e.g., `Cover`, `Body`, `Insert`). Reuses `ProductType` enum.
        description: { type: string }
        intent: { $ref: "#/components/schemas/PartIntent" }
        pageRanges:
          type: array
          minItems: 1
          items: { $ref: "#/components/schemas/GenericPageRange" }

    PartIntent:
      type: object
      description: |
        `layoutIntent` is required for correct portal slot rendering. `colorIntent`
        and `mediaIntent` drive preflight defaults.
      properties:
        layoutIntent:
          type: object
          required: [pages, sides, dimensions, numberUp]
          properties:
            pages: { type: integer, minimum: 1 }
            sides:
              type: string
              enum: [OneSided, OneSidedBack, TwoSidedHeadToHead, TwoSidedHeadToFoot, Unprinted]
            orientation:
              type: string
              enum: [Rotate0, Rotate90, Rotate180, Rotate270]
            dimensions:
              type: object
              required: [width, height]
              properties:
                width:  { type: number, description: "Points (1pt = 1/72 inch)." }
                height: { type: number }
            finishedDimensions:
              type: object
              properties:
                width:  { type: number }
                height: { type: number }
                depth:  { type: number }
            bleed:
              type: number
              description: Points on each edge.
            numberUp:
              type: object
              properties:
                width:  { type: integer, minimum: 1 }
                height: { type: integer, minimum: 1 }
            spreadType:
              type: string
              enum: [SinglePage, Spread]
        colorIntent:
          type: object
          properties:
            surfaceColor:
              type: object
              properties:
                front: { $ref: "#/components/schemas/SurfaceColor" }
                back:  { $ref: "#/components/schemas/SurfaceColor" }
        mediaIntent:
          type: object
          properties:
            mediaType:
              type: string
              description: "`Paper`, `Synthetic`, `Film`, `Textile`, `SelfAdhesive`, `Transparency`, `CorrugatedBoard`, `Other`."
            weight: { type: number, description: "g/m²" }
            thickness: { type: number, description: "mm" }
            mediaQuality: { type: string }
            brand: { type: string }

    SurfaceColor:
      type: object
      properties:
        colorsUsed:
          type: array
          items: { type: string }
          description: e.g. `["Cyan","Magenta","Yellow","Black"]` for 4-color process.
        coatings:
          type: array
          items: { type: string }
        printStandard:
          type: string
          description: "`FOGRA39`, `FOGRA51`, `FOGRA52`, `FOGRA53` (ISO-12647)."
        surface:
          type: string
          enum: [Front, Back]

    GenericPageRange:
      type: object
      required: [value, variants]
      properties:
        value:
          type: string
          description: Page expression — `"1"`, `"1-4"`, `"1,3,5"`, etc. Matches IWS page-range syntax.
        colors:
          type: array
          items: { type: string }
          description: Colors expected in this page range. Used by preflight.
        amount:
          type: integer
          minimum: 1
          description: Number of distinct files expected for this slot. Typically `1`.
        variants:
          type: array
          items: { $ref: "#/components/schemas/GenericVariant" }

    GenericVariant:
      type: object
      required: [name]
      properties:
        name:
          type: string
          description: Short label (e.g. `"default"`, `"German"`, `"English"`, `"A"`, `"B"`).
        amount:
          type: integer
          minimum: 0
          description: Run quantity for this variant. Sum across variants in a product should equal `product.amount`.
        processingSteps:
          type: array
          items:
            type: object
            description: IWS `ProcessingStep`. Leave empty if no post-press steps.
            additionalProperties: true
        resourceRefs:
          type: array
          items: { type: string }
          description: Pre-populated resource IDs to associate with this variant. Usually empty — customer uploads in the portal.

    OrderResponse:
      type: object
      required: [order]
      properties:
        order: { $ref: "#/components/schemas/OrderWithHierarchy" }
        portalIconUrl: { type: string, format: uri, nullable: true }
        portalFeatures:
          type: object
          description: |
            Tenant-configured portal feature flags. Opaque to shops — do not rely
            on specific keys.
          additionalProperties: true

    PaginatedOrderResponse:
      type: object
      required: [orders, pagination]
      properties:
        orders:
          type: array
          items: { $ref: "#/components/schemas/OrderResponse" }
        pagination:
          type: object
          required: [page, pageSize, total, totalPages]
          properties:
            page: { type: integer }
            pageSize: { type: integer }
            total: { type: integer }
            totalPages: { type: integer }

    OrderWithHierarchy:
      type: object
      required: [id, name, status, milestone]
      properties:
        id: { type: string, format: uuid }
        orderNumber: { type: integer, nullable: true }
        name: { type: string }
        description: { type: string, nullable: true }
        status:
          $ref: "#/components/schemas/Status"
        milestone:
          $ref: "#/components/schemas/Milestone"
        error: { type: string, nullable: true }
        externalOrderId: { type: string, nullable: true }
        comment: { type: string, nullable: true }
        confirmedAt: { type: string, format: date-time, nullable: true }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
        sharedKey: { type: string, nullable: true, description: "Active customer share key, if any." }
        products:
          type: array
          items: { $ref: "#/components/schemas/ProductWithHierarchy" }

    ProductWithHierarchy:
      type: object
      required: [id, name, type]
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        type: { type: string }
        amount: { type: integer }
        confirmedAt: { type: string, format: date-time, nullable: true }
        parts:
          type: array
          items:
            type: object
            properties:
              id: { type: string, format: uuid }
              name: { type: string }
              type: { type: string }
              pageRanges:
                type: array
                items:
                  type: object
                  properties:
                    id: { type: string, format: uuid }
                    amount: { type: integer }
                    variants:
                      type: array
                      items:
                        type: object
                        properties:
                          id: { type: string, format: uuid }
                          name: { type: string }
                          resources:
                            type: array
                            items: { $ref: "#/components/schemas/ResourceSummary" }

    ResourceSummary:
      type: object
      required: [id]
      properties:
        id: { type: string, format: uuid }
        name: { type: string, nullable: true }
        mimeType: { type: string, nullable: true }
        pageCount: { type: integer, nullable: true }

    ProductResourcesResponse:
      type: object
      required: [resources]
      properties:
        resources:
          type: array
          items:
            type: object
            required: [id]
            properties:
              id: { type: string, format: uuid }
              name: { type: string, nullable: true }
              mimeType: { type: string, nullable: true }
              pageCount: { type: integer, nullable: true }
              type:
                type: string
                description: Resource type, e.g. `Merged`, `Preflighted`.
              uri:
                type: string
                description: Internal S3 URI. Not directly fetchable — use `downloadUrl`.
              downloadUrl:
                type: string
                format: uri
                nullable: true
                description: |
                  Presigned S3 URL, valid **7 days**. Present unless
                  `withDownloadUrls=false` is passed.

    ShareLinkResponse:
      type: object
      required: [sharedKey]
      properties:
        sharedKey:
          type: string
          description: |
            Opaque customer-facing token. Construct the URL as
            `${PORTAL_URL}/s/${sharedKey}`. Valid 7 days.

    Status:
      type: string
      description: IWS-aligned order status. See `backend/src/types/upl.ts`.
      enum:
        - new
        - pending
        - in-progress
        - completed
        - error
        - archived

    Milestone:
      type: string
      description: Internal workflow milestone. See `Milestones` enum in `backend/src/types/upl.ts`.

    WebhookPayload:
      type: object
      required: [event, timestamp, order]
      properties:
        event:
          type: string
          enum: [order.confirmed]
        timestamp:
          type: string
          format: date-time
          description: ISO-8601 timestamp of the event.
        order:
          type: object
          required: [id, name, products]
          properties:
            id: { type: string, format: uuid }
            name: { type: string }
            reference:
              type: string
              nullable: true
              description: Mirror of `order.externalOrderId`.
            comment: { type: string, nullable: true }
            confirmedAt: { type: string, format: date-time, nullable: true }
            products:
              type: array
              items:
                type: object
                properties:
                  id: { type: string, format: uuid }
                  name: { type: string }
                  type: { type: string }
                  amount: { type: integer }
                  confirmedAt: { type: string, format: date-time, nullable: true }
                  resources:
                    type: array
                    description: Top-level product resources (merged + preflighted PDFs) with presigned download URLs.
                    items:
                      allOf:
                        - $ref: "#/components/schemas/ResourceSummary"
                        - type: object
                          properties:
                            downloadUrl: { type: string, format: uri }
                  parts:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, format: uuid }
                        name: { type: string }
                        type: { type: string }
                        pageRanges:
                          type: array
                          items:
                            type: object
                            properties:
                              id: { type: string, format: uuid }
                              amount: { type: integer }
                              variants:
                                type: array
                                items:
                                  type: object
                                  properties:
                                    id: { type: string, format: uuid }
                                    name: { type: string }
                                    resources:
                                      type: array
                                      items:
                                        allOf:
                                          - $ref: "#/components/schemas/ResourceSummary"
                                          - type: object
                                            properties:
                                              downloadUrl: { type: string, format: uri }
