# TracePass v1 API — OpenAPI 3.1 specification
# ---------------------------------------------------------------------
# Hand-written, cross-checked against the platform's Zod schemas in
# src/lib/validation/schemas.ts and the route handlers under
# src/app/api/v1/. Authoritative reference for every public endpoint
# TracePass exposes; the prose docs at https://www.tracepass.eu/docs
# wrap the same vocabulary in a more readable form.
#
# Format conventions:
#   - All v1 endpoints require an API key as a Bearer token in the
#     Authorization header (BearerAuth security scheme below).
#   - All write endpoints accept an Idempotency-Key header with
#     24-hour replay semantics.
#   - GTINs are 14 digits with a valid GS1 check digit; serials are
#     unique within a GTIN; GLNs are 13 digits with mod-10 check
#     digit.
#   - The bulk JSON-LD tenant export uses session-cookie auth
#     instead — it is intentionally NOT API-key reachable.

openapi: 3.1.0

info:
  title: TracePass API
  version: "1.0.0"
  summary: Digital Product Passport REST API for the TracePass platform.
  description: |
    REST API for managing Digital Product Passports on TracePass —
    products, passports, parties (economic operators), bulk batches,
    and the JSON-LD tenant export.

    Most operations require an API key in the standard
    `Authorization: Bearer <key>` header. v1 access is gated to the
    Growth plan and above; Free / Basic / Starter workspaces don't
    see the API key minter in the dashboard.

    Worked examples in curl / TypeScript / Python live alongside
    each endpoint at https://www.tracepass.eu/docs.
  contact:
    name: TracePass support
    email: support@tracepass.eu
    url: https://www.tracepass.eu
  license:
    name: TracePass Terms of Service
    url: https://www.tracepass.eu/terms
  termsOfService: https://www.tracepass.eu/terms

servers:
  - url: https://app.tracepass.eu
    description: Production

externalDocs:
  description: Prose documentation with worked examples
  url: https://www.tracepass.eu/docs

tags:
  - name: Passports
    description: |
      Create, read, update, suspend, archive, and bulk-import Digital
      Product Passports. Includes the parties block for economic-
      operator chains.
    externalDocs:
      url: https://www.tracepass.eu/docs/passports
  - name: Products
    description: |
      The catalogue layer — products, images, defaults. One product
      can have many passports (one per serialised unit).
    externalDocs:
      url: https://www.tracepass.eu/docs/products
  - name: Exports
    description: |
      Bulk JSON-LD tenant export. Dashboard-cookie auth, not API
      key — see the operation's security block.
    externalDocs:
      url: https://www.tracepass.eu/docs/exports

# Default security applied to every operation. The tenant-export
# operation overrides this with its own (cookie-based) requirement.
security:
  - BearerAuth: []

paths:
  # =====================================================================
  # Passports
  # =====================================================================

  /api/v1/passports:
    post:
      operationId: createPassport
      tags: [Passports]
      summary: Create a single passport.
      description: |
        Create a single Digital Product Passport. The passport is
        bound to a product (`productId`) and identified by a GS1
        GTIN + serial unique within that GTIN. New passports start
        in `draft` status — fields are populated via subsequent
        PATCH calls and the passport is published from the
        dashboard once review is complete.

        Counts as one v1 write AND consumes one DPP slot from the
        plan's `maxDpps` quota — when that quota is exhausted and
        the plan supports overage, returns 402 with an
        `overage_required` body. Retry with
        `confirmOverage: true` to accept the per-passport charge.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreatePassportInput"
            example:
              productId: "6650a1b2c3d4e5f6a7b8c9d0"
              gs1:
                gtin: "04012345000015"
                serialNumber: "BP-48V-100-000001"
      responses:
        "201":
          description: Passport created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/OverageRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

    get:
      operationId: listPassports
      tags: [Passports]
      summary: List passports.
      description: |
        Paginated list of passports the workspace owns. Filter by
        product, status, or a free-text search across GTIN and
        serial.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: productId
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/ObjectId"
        - name: status
          in: query
          required: false
          schema:
            $ref: "#/components/schemas/PassportStatus"
        - name: search
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Paginated passport list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/PaginatedPassports"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    get:
      operationId: getPassport
      tags: [Passports]
      summary: Read one passport.
      description: |
        Default response includes the full passport — translatable
        fields carry their `sourceLocale` and a per-locale
        `translations` map. `?lang=<locale>` resolves every field
        through the public-viewer chain and returns one resolved
        value per field; `?format=full` adds template-derived
        labels, units, and access levels alongside each value.

        An alternate addressing form exists at
        `GET /api/v1/passports/by-serial/{serial}` with the same
        body and query-parameter behaviour.
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [full]
          description: |
            Set to `full` to include template field labels, units,
            and access levels alongside each field value.
        - name: lang
          in: query
          required: false
          schema:
            type: string
          description: |
            ISO 639-1 locale to resolve every field value through
            the public-viewer chain (viewer-locale translation →
            source-locale value → English → first-applied →
            universal source). One of the 24 EU locales. When set,
            the `translations` map is dropped from each field.
      responses:
        "200":
          description: Passport.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/fields/{key}:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/FieldKey"
      - $ref: "#/components/parameters/IdempotencyKey"
    patch:
      operationId: updatePassportField
      tags: [Passports]
      summary: Update one field on a passport.
      description: |
        Patch a single field on a passport. The value is validated
        against the field key (must exist on the passport's
        template) and persisted with an audit-trail entry tagged
        `via API key <prefix>`. Writes default to `status:
        "approved"` — API-key-driven integrations are trusted by
        convention. Override with `source: "ai_suggested"` or
        `source: "supplier"` to land in the review queue instead.

        An alternate addressing form exists at PATCH
        `/api/v1/passports/by-serial/{serial}/fields/{key}` with
        the same body shape.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateFieldInput"
            example:
              value: 5.24
      responses:
        "200":
          description: Field updated.
          content:
            application/json:
              schema:
                type: object
                required: [field, version]
                properties:
                  field:
                    $ref: "#/components/schemas/PassportField"
                  version:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/parties/{role}:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/PartyRole"
      - $ref: "#/components/parameters/IdempotencyKey"
    patch:
      operationId: upsertParty
      tags: [Passports]
      summary: Upsert an economic-operator party.
      description: |
        Create or replace the Party for one role on a passport.
        Sending the same role twice is an upsert: the existing
        block is replaced atomically. At least one of `gln` or
        `legacyOperatorId` must be set in the body — without an
        identifier the Party doesn't actually identify anyone.

        When two roles share the same `gln`, the JSON-LD emission
        collapses them into a single party with multiple roles
        attached.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/Party"
            example:
              legalName: "EuroBat Recyclers GmbH"
              gln: "4012345678901"
              country: "DE"
              url: "https://eurobat-recyclers.example"
      responses:
        "200":
          description: Party upserted.
          content:
            application/json:
              schema:
                type: object
                required: [role, party, version]
                properties:
                  role:
                    $ref: "#/components/schemas/PartyRoleEnum"
                  party:
                    $ref: "#/components/schemas/Party"
                  version:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"
    delete:
      operationId: deleteParty
      tags: [Passports]
      summary: Remove a party from a passport.
      description: |
        Remove the Party for one role on a passport. Idempotent —
        removing an absent role returns `{ removed: false }`
        without bumping the passport version.
      responses:
        "200":
          description: Party removed (or already absent).
          content:
            application/json:
              schema:
                type: object
                required: [role, removed]
                properties:
                  role:
                    $ref: "#/components/schemas/PartyRoleEnum"
                  removed:
                    type: boolean
                  version:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/suspend:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: suspendPassport
      tags: [Passports]
      summary: Suspend a passport (reversible).
      description: |
        Reversible suspend. The public viewer flips to the
        suspended state page (HTTP 423 with structured body); QR
        scans effectively die without the URL going to 404.

        Optional `reason` body surfaces in the
        `passport.suspended` webhook payload and the dashboard
        audit trail (truncated at 500 chars). Empty body is fine.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
            example:
              reason: "Quality investigation pending — batch BB-2026-04-12."
      responses:
        "200":
          description: Suspended.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Wrong status to suspend from.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/archive:
    parameters:
      - $ref: "#/components/parameters/PassportId"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: archivePassport
      tags: [Passports]
      summary: Archive a passport (irreversible).
      description: |
        **Irreversible.** Public viewer returns 404, the GS1
        Digital Link URL stops resolving, the QR code dies for
        good. Use ONLY for products that never shipped — archiving
        a passport for a product already in customers' hands
        breaks every QR scan they'll ever make.

        The HTTP method is POST and the path includes a literal
        `archive` segment, both intentional friction. Fires the
        `passport.archived` webhook.
      responses:
        "200":
          description: Archived.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Already archived.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/batch:
    post:
      operationId: batchCreatePassports
      tags: [Passports]
      summary: Batch-create passports (up to 100 per call).
      description: |
        Create up to 100 passports in one call. Each item carries
        the same body shape as the single-create endpoint;
        partial-success per item — each gets its own status in the
        response array.

        The whole batch consumes N writes upfront against the
        daily-write budget; if that would overflow, the entire
        batch returns 429 (no partial billing). Same applies to
        DPP overage at the batch level — when the DPP quota would
        be exceeded the batch returns 402 with `overage_required`,
        and `confirmOverage: true` accepts the overage charge for
        the whole batch.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/BatchCreatePassportsInput"
      responses:
        "200":
          description: Partial-success batch result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchPassportsResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/OverageRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/{id}/qr:
    parameters:
      - $ref: "#/components/parameters/PassportId"
    get:
      operationId: getPassportQr
      tags: [Passports]
      summary: Render the passport's QR code.
      description: |
        Returns the QR code for a passport. Default response is a
        raw SVG image so `<img src=".../qr">` works directly. Use
        `?format=png` for a raster PNG, or `?format=json` for an
        envelope containing both the SVG markup and a base64
        PNG data URI plus the encoded URL + status.

        Counts as one v1 passport-read against the daily budget
        (same counter as the data endpoint).
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [svg, png, json]
            default: svg
        - name: useCompanyBranding
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
          description: |
            When set to `true`, foreground colour comes from the
            company's `branding.primaryColor`. Ignored if `color`
            is also set.
        - name: color
          in: query
          required: false
          schema:
            type: string
            pattern: "^[0-9A-Fa-f]{6}$"
          description: 6-char hex without `#`. Wins over useCompanyBranding.
        - name: backgroundColor
          in: query
          required: false
          schema:
            type: string
            pattern: "^[0-9A-Fa-f]{6,8}$"
          description: 6 or 8 char hex without `#` (8 = RGBA).
      responses:
        "200":
          description: QR rendering.
          content:
            image/svg+xml:
              schema:
                type: string
            image/png:
              schema:
                type: string
                format: binary
            application/json:
              schema:
                $ref: "#/components/schemas/QrJsonEnvelope"
        "400":
          description: Invalid colour parameter.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  # =====================================================================
  # By-serial alternate addressing — same operations as the by-id forms,
  # just with the customer's own serial in the path. Useful when the ERP
  # only carries the serial. Bodies and responses are byte-identical to
  # the by-id siblings.
  # =====================================================================

  /api/v1/passports/by-serial/{serial}:
    parameters:
      - $ref: "#/components/parameters/Serial"
    get:
      operationId: getPassportBySerial
      tags: [Passports]
      summary: Read one passport by serial.
      description: |
        By-serial addressing for the read endpoint — same response
        shape and same query parameters (`?lang=`, `?format=full`)
        as `GET /api/v1/passports/{id}`.
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [full]
        - name: lang
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Passport.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/qr:
    parameters:
      - $ref: "#/components/parameters/Serial"
    get:
      operationId: getPassportQrBySerial
      tags: [Passports]
      summary: Render the passport's QR code (by serial).
      description: Same shape as `GET /api/v1/passports/{id}/qr`.
      parameters:
        - name: format
          in: query
          required: false
          schema:
            type: string
            enum: [svg, png, json]
            default: svg
        - name: useCompanyBranding
          in: query
          required: false
          schema:
            type: string
            enum: ["true"]
        - name: color
          in: query
          required: false
          schema:
            type: string
            pattern: "^[0-9A-Fa-f]{6}$"
        - name: backgroundColor
          in: query
          required: false
          schema:
            type: string
            pattern: "^[0-9A-Fa-f]{6,8}$"
      responses:
        "200":
          description: QR rendering.
          content:
            image/svg+xml:
              schema:
                type: string
            image/png:
              schema:
                type: string
                format: binary
            application/json:
              schema:
                $ref: "#/components/schemas/QrJsonEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/fields/{key}:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/FieldKey"
      - $ref: "#/components/parameters/IdempotencyKey"
    patch:
      operationId: updatePassportFieldBySerial
      tags: [Passports]
      summary: Update one field on a passport (by serial).
      description: |
        Same body, same response, and same semantics as PATCH
        `/api/v1/passports/{id}/fields/{key}`.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateFieldInput"
      responses:
        "200":
          description: Field updated.
          content:
            application/json:
              schema:
                type: object
                required: [field, version]
                properties:
                  field:
                    $ref: "#/components/schemas/PassportField"
                  version:
                    type: integer
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/suspend:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: suspendPassportBySerial
      tags: [Passports]
      summary: Suspend a passport (by serial).
      description: Same shape as POST `/api/v1/passports/{id}/suspend`.
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reason:
                  type: string
                  maxLength: 500
      responses:
        "200":
          description: Suspended.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Wrong status to suspend from.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/passports/by-serial/{serial}/archive:
    parameters:
      - $ref: "#/components/parameters/Serial"
      - $ref: "#/components/parameters/IdempotencyKey"
    post:
      operationId: archivePassportBySerial
      tags: [Passports]
      summary: Archive a passport (by serial, irreversible).
      description: Same shape as POST `/api/v1/passports/{id}/archive`. **Irreversible.**
      responses:
        "200":
          description: Archived.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Passport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          description: Already archived.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  # =====================================================================
  # Products
  # =====================================================================

  /api/v1/products:
    post:
      operationId: createProduct
      tags: [Products]
      summary: Create a product.
      description: |
        Create a product. The category template is resolved
        automatically from the `category` slug — must match a
        seeded category. `model` strings are unique within the
        workspace.
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateProductInput"
            example:
              name: "Li-Ion 48V Battery Pack"
              model: "BP-48V-100"
              category: "batteries"
              defaultFieldValues:
                battery_chemistry: "lithium-ion"
                nominal_voltage: 48
      responses:
        "201":
          description: Product created.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

    get:
      operationId: listProducts
      tags: [Products]
      summary: List products.
      description: Paginated list of products in the workspace.
      parameters:
        - $ref: "#/components/parameters/Page"
        - $ref: "#/components/parameters/Limit"
        - name: category
          in: query
          required: false
          schema:
            type: string
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [active, archived]
        - name: search
          in: query
          required: false
          schema:
            type: string
      responses:
        "200":
          description: Paginated product list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    $ref: "#/components/schemas/PaginatedProducts"
                required: [data]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/products/{id}:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    get:
      operationId: getProduct
      tags: [Products]
      summary: Read one product.
      description: |
        Read one product by ID. Returns the full document
        including default field values, image URLs, template
        reference, and the running `passportCount`.
      responses:
        "200":
          description: Product.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimit"
    patch:
      operationId: updateProduct
      tags: [Products]
      summary: Update a product.
      description: |
        Patch one or more product fields. Send only the keys you
        want to change — omitted fields stay untouched.

        `imageUrls` REPLACES the existing array (deliberate — your
        CMS stays canonical). To append a single image without
        rewriting the list, use the multipart upload endpoint.
        `description: null` clears the description (distinct from
        omitting the key).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/UpdateProductInput"
      responses:
        "200":
          description: Product updated.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Product"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "409":
          $ref: "#/components/responses/Conflict"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/products/batch:
    post:
      operationId: batchCreateProducts
      tags: [Products]
      summary: Batch-create products (up to 100 per call).
      description: |
        Create up to 100 products in one call. Each item carries
        the same body shape as the single-create endpoint.
        Partial-success per item — each gets its own status in
        the response array.

        The whole batch consumes N writes upfront against the
        daily-write budget; if that would overflow, the entire
        batch returns 429 (no partial billing).
      parameters:
        - $ref: "#/components/parameters/IdempotencyKey"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [products]
              properties:
                products:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    $ref: "#/components/schemas/CreateProductInput"
      responses:
        "200":
          description: Partial-success batch result.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/BatchProductsResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/IdempotencyConflict"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/products/{id}/images:
    parameters:
      - $ref: "#/components/parameters/ProductId"
    post:
      operationId: uploadProductImage
      tags: [Products]
      summary: Upload a product image.
      description: |
        Upload a single image file via `multipart/form-data` (field
        name `file`) and append it to the product's `imageUrls`
        array. Returns the resulting public R2 URL plus the full
        updated array.

        PNG / JPG / WebP only, max 5 MB per file, max 20 images
        per product. **No Idempotency-Key support** — multipart
        bodies aren't safely hashable; the client should check
        existence and skip if retrying.
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: Image bytes (PNG, JPG, or WebP, ≤ 5 MB).
      responses:
        "201":
          description: Image uploaded.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UploadImageResponse"
        "400":
          description: No file provided.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          $ref: "#/components/responses/NotFound"
        "413":
          description: Image larger than 5 MB.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "415":
          description: Unsupported MIME type.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "422":
          description: Product already has 20 images (max).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  /api/v1/products/{id}/images/{index}:
    parameters:
      - $ref: "#/components/parameters/ProductId"
      - name: index
        in: path
        required: true
        schema:
          type: integer
          minimum: 0
        description: Zero-based image index in the product's `imageUrls` array.
    delete:
      operationId: deleteProductImage
      tags: [Products]
      summary: Remove a product image by index.
      description: |
        Remove a single image from `imageUrls` by its zero-based
        index. The underlying R2 object is not deleted; the URL
        just stops being referenced.
      responses:
        "200":
          description: Image removed.
          content:
            application/json:
              schema:
                type: object
                required: [removed, imageUrls]
                properties:
                  removed:
                    type: string
                    format: uri
                  imageUrls:
                    type: array
                    items:
                      type: string
                      format: uri
        "400":
          description: Invalid image index.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "404":
          description: Index out of range, or product not found.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "429":
          $ref: "#/components/responses/RateLimit"

  # =====================================================================
  # Exports
  # =====================================================================

  /api/exports/tenant:
    get:
      operationId: tenantExport
      tags: [Exports]
      summary: Bulk JSON-LD tenant export.
      description: |
        Returns every product, every passport (regardless of
        status), and every referenced category template the
        workspace owns, as one JSON-LD document.

        **Not API-key reachable.** Auth is dashboard JWT (admin
        role) + paying-customer gate — same trust level as
        deleting the company. The pragmatic flow is: open Settings
        → Data export in the dashboard and click "Export tenant".
        Programmatic access requires a session cookie issued by
        `/api/auth/login`.

        Synchronous response with `Content-Disposition:
        attachment; filename="tracepass-tenant-export-<companyId>-<YYYY-MM-DD>.jsonld"`.
        No pagination — all-or-nothing.
      security:
        - SessionCookie: []
      parameters:
        - name: Accept
          in: header
          required: false
          schema:
            type: string
            enum:
              - application/ld+json
              - application/json
            default: application/ld+json
      responses:
        "200":
          description: Tenant export.
          headers:
            Content-Disposition:
              schema:
                type: string
              description: |
                attachment; filename="tracepass-tenant-export-<companyId>-<YYYY-MM-DD>.jsonld"
          content:
            application/ld+json:
              schema:
                $ref: "#/components/schemas/TenantExport"
            application/json:
              schema:
                $ref: "#/components/schemas/TenantExport"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          description: Active paid subscription required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"
        "403":
          description: Admin role required.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorEnvelope"

components:
  securitySchemes:
    BearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque
      description: |
        API-key Bearer token. Mint at Developer → API keys in the
        dashboard (Growth+ plans only). Keys carry the `tp_`
        prefix followed by an opaque random suffix.
    SessionCookie:
      type: apiKey
      in: cookie
      name: tp_session
      description: |
        Dashboard session cookie issued by `/api/auth/login`.
        Used only for the bulk JSON-LD tenant export, which is
        deliberately not exposed via API key.

  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      schema:
        type: string
        maxLength: 255
      description: |
        UUID v4 (or any opaque string ≤ 255 chars) per logical
        operation. The platform stores the first response for 24
        hours and replays it on the same key + same workspace.
        Reusing the same key with a different request body returns
        422.

    PassportId:
      name: id
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/ObjectId"

    ProductId:
      name: id
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/ObjectId"

    PartyRole:
      name: role
      in: path
      required: true
      schema:
        $ref: "#/components/schemas/PartyRoleEnum"

    FieldKey:
      name: key
      in: path
      required: true
      schema:
        type: string
      description: |
        Field key (snake_case) as defined on the passport's
        category template — e.g. `nominal_voltage`,
        `recycled_content_pct`, `country_of_origin`.

    Serial:
      name: serial
      in: path
      required: true
      schema:
        type: string
        minLength: 1
        maxLength: 100
      description: |
        Passport serial number, as supplied by the customer at
        passport-create time. Used by the by-serial alternate
        addressing forms.

    Page:
      name: page
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        default: 1

    Limit:
      name: limit
      in: query
      required: false
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20

  schemas:
    ObjectId:
      type: string
      pattern: "^[a-f0-9]{24}$"
      example: "6650a1b2c3d4e5f6a7b8c9d0"
      description: MongoDB ObjectId hex string (24 chars).

    GTIN:
      type: string
      pattern: "^[0-9]{14}$"
      example: "04012345000015"
      description: GS1 GTIN-14 with valid check digit.

    GLN:
      type: string
      pattern: "^[0-9]{13}$"
      example: "4012345678901"
      description: GS1 Global Location Number (13 digits, mod-10).

    PartyRoleEnum:
      type: string
      enum:
        - manufacturer
        - importer
        - authorisedRepresentative
        - distributor
        - recycler
        - producerResponsibilityOrg

    Party:
      type: object
      required: [legalName]
      description: |
        Economic-operator party. At least one of `gln` or
        `legacyOperatorId` must be set — without an identifier the
        Party doesn't actually identify anyone.
      properties:
        gln:
          $ref: "#/components/schemas/GLN"
        legalName:
          type: string
          minLength: 1
          maxLength: 200
          example: "EuroBat Recyclers GmbH"
        country:
          type: string
          pattern: "^[A-Z]{2}$"
          description: ISO 3166-1 alpha-2 country code.
          example: "DE"
        legacyOperatorId:
          type: string
          minLength: 1
          maxLength: 120
          description: |
            Free-text fallback identifier (VAT, EORI, supplier
            code) for parties without a GLN.
          example: "DE123456789"
        url:
          type: string
          format: uri
          maxLength: 500
        status:
          type: string
          readOnly: true
          description: Server-side status (active, etc.). Returned but ignored on write.

    PartyMap:
      type: object
      additionalProperties:
        $ref: "#/components/schemas/Party"
      description: |
        Map of role → Party. Each role appears at most once.

    GS1Block:
      type: object
      required: [gtin, serialNumber]
      properties:
        gtin:
          $ref: "#/components/schemas/GTIN"
        serialNumber:
          type: string
          minLength: 1
          maxLength: 100
          example: "BP-48V-100-000001"
        digitalLinkUri:
          type: string
          format: uri
          readOnly: true
          example: "https://id.tracepass.eu/p/01/04012345000015/21/BP-48V-100-000001"

    PassportStatus:
      type: string
      enum:
        - draft
        - in_review
        - approved
        - published
        - suspended
        - expired
        - archived

    PassportField:
      type: object
      properties:
        value: {}
        source:
          type: string
          enum: [manual, ai_suggested, ai_approved, reference_db, supplier, system]
        status:
          type: string
        accessLevel:
          type: string
        sourceLocale:
          type: string
        translations:
          type: object
          additionalProperties:
            type: object
            properties:
              value: {}
              source:
                type: string
              generatedAt:
                type: string
                format: date-time
        lastUpdatedAt:
          type: string
          format: date-time
        lastUpdatedBy:
          type: string

    Passport:
      type: object
      properties:
        _id:
          $ref: "#/components/schemas/ObjectId"
        companyId:
          $ref: "#/components/schemas/ObjectId"
        productId:
          $ref: "#/components/schemas/ObjectId"
        templateId:
          $ref: "#/components/schemas/ObjectId"
        gs1:
          $ref: "#/components/schemas/GS1Block"
        status:
          $ref: "#/components/schemas/PassportStatus"
        completionPercentage:
          type: integer
          minimum: 0
          maximum: 100
        fieldCounts:
          type: object
          properties:
            total:
              type: integer
            empty:
              type: integer
            approved:
              type: integer
            pendingReview:
              type: integer
            flagged:
              type: integer
        fields:
          type: object
          additionalProperties:
            $ref: "#/components/schemas/PassportField"
        parties:
          $ref: "#/components/schemas/PartyMap"
        publishedAt:
          type: string
          format: date-time
        suspendedAt:
          type: string
          format: date-time
        suspensionReason:
          type: string
        archivedAt:
          type: string
          format: date-time
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    PassportSummary:
      type: object
      description: Trimmed passport shape returned in list responses.
      properties:
        _id:
          $ref: "#/components/schemas/ObjectId"
        productId:
          $ref: "#/components/schemas/ObjectId"
        gs1:
          $ref: "#/components/schemas/GS1Block"
        status:
          $ref: "#/components/schemas/PassportStatus"
        completionPercentage:
          type: integer
        publishedAt:
          type: string
          format: date-time

    Product:
      type: object
      properties:
        _id:
          $ref: "#/components/schemas/ObjectId"
        name:
          type: string
        model:
          type: string
        category:
          type: string
        templateId:
          $ref: "#/components/schemas/ObjectId"
        defaultFieldValues:
          type: object
          additionalProperties: {}
        imageUrls:
          type: array
          items:
            type: string
            format: uri
            maxLength: 2048
          maxItems: 20
        passportCount:
          type: integer
        status:
          type: string
          enum: [active, archived]
        sourceLocale:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time

    CreatePassportInput:
      type: object
      required: [productId, gs1]
      properties:
        productId:
          $ref: "#/components/schemas/ObjectId"
        gs1:
          type: object
          required: [gtin, serialNumber]
          properties:
            gtin:
              $ref: "#/components/schemas/GTIN"
            serialNumber:
              type: string
              minLength: 1
              maxLength: 100
        parties:
          $ref: "#/components/schemas/PartyMap"
        confirmOverage:
          type: boolean
          description: |
            Accept the per-passport overage charge when the plan's
            `maxDpps` quota is already exhausted. Required only
            after a 402 response on a previous attempt.

    UpdateFieldInput:
      type: object
      required: [value]
      properties:
        value:
          description: |
            New value, of the type the field's template entry
            expects. Number fields take a raw number, not a string.
        source:
          type: string
          enum:
            - manual
            - ai_suggested
            - ai_approved
            - reference_db
            - supplier
            - system
          description: |
            Tag the origin of the value. Default `manual`.
            `ai_suggested` and `supplier` land in the review queue;
            everything else writes as approved.
        sourceLocale:
          type: string
          description: |
            ISO 639-1 locale of the value. One of the 24 EU
            locales. Defaults to the passport's `sourceLocale`.

    CreateProductInput:
      type: object
      required: [name, model, category]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        model:
          type: string
          minLength: 1
          maxLength: 100
          description: Unique within the workspace.
        category:
          type: string
          description: |
            Seeded category slug — `batteries`, `textiles`,
            `jewelry`, `electronics`, `furniture`, `chemicals`,
            `packaging`, `toys`, `fmcg`, `tyres`, `iron-steel`,
            `construction`.
        description:
          type: string
          maxLength: 2000
        defaultFieldValues:
          type: object
          additionalProperties: {}
        imageUrls:
          type: array
          items:
            type: string
            format: uri
            maxLength: 2048
          maxItems: 20
        sourceLocale:
          type: string

    UpdateProductInput:
      type: object
      description: |
        Send only the keys you want to change. `imageUrls`
        REPLACES the existing array. `description: null` clears
        the description.
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 200
        model:
          type: string
          minLength: 1
          maxLength: 100
        description:
          oneOf:
            - type: string
              maxLength: 2000
            - type: "null"
        defaultFieldValues:
          type: object
          additionalProperties: {}
        imageUrls:
          type: array
          items:
            type: string
            format: uri
            maxLength: 2048
          maxItems: 20
        status:
          type: string
          enum: [active, archived]
        sourceLocale:
          type: string

    UploadImageResponse:
      type: object
      required: [imageUrl, imageUrls]
      properties:
        imageUrl:
          type: string
          format: uri
        imageUrls:
          type: array
          items:
            type: string
            format: uri

    PaginationEnvelope:
      type: object
      required: [items, total, page, limit, totalPages]
      properties:
        total:
          type: integer
        page:
          type: integer
        limit:
          type: integer
        totalPages:
          type: integer

    PaginatedPassports:
      allOf:
        - $ref: "#/components/schemas/PaginationEnvelope"
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/PassportSummary"

    PaginatedProducts:
      allOf:
        - $ref: "#/components/schemas/PaginationEnvelope"
        - type: object
          required: [items]
          properties:
            items:
              type: array
              items:
                $ref: "#/components/schemas/Product"

    BatchResultItem:
      oneOf:
        - type: object
          required: [index, status, data]
          properties:
            index:
              type: integer
            status:
              type: string
              enum: [created]
            data:
              $ref: "#/components/schemas/Passport"
        - type: object
          required: [index, status, error]
          properties:
            index:
              type: integer
            status:
              type: string
              enum: [error]
            error:
              type: string

    BatchSummary:
      type: object
      required: [created, errors, total]
      properties:
        created:
          type: integer
        errors:
          type: integer
        total:
          type: integer

    BatchPassportsResponse:
      type: object
      required: [results, summary]
      properties:
        results:
          type: array
          items:
            $ref: "#/components/schemas/BatchResultItem"
        summary:
          $ref: "#/components/schemas/BatchSummary"

    BatchCreatePassportsInput:
      type: object
      required: [passports]
      description: |
        Top-level `confirmOverage` is the BATCH-LEVEL flag — set
        it to accept the per-passport overage charge if the entire
        batch would exceed the workspace's `maxDpps` quota. Wins
        over any per-item `confirmOverage` (the single-create
        flag, which is also accepted on each item but only relevant
        if a single item would exhaust the budget on its own).
      properties:
        passports:
          type: array
          minItems: 1
          maxItems: 100
          items:
            $ref: "#/components/schemas/CreatePassportInput"
        confirmOverage:
          type: boolean

    BatchProductResultItem:
      oneOf:
        - type: object
          required: [index, status, data]
          properties:
            index:
              type: integer
            status:
              type: string
              enum: [created]
            data:
              $ref: "#/components/schemas/Product"
        - type: object
          required: [index, status, error]
          properties:
            index:
              type: integer
            status:
              type: string
              enum: [error]
            error:
              type: string

    BatchProductsResponse:
      type: object
      required: [results, summary]
      properties:
        results:
          type: array
          items:
            $ref: "#/components/schemas/BatchProductResultItem"
        summary:
          $ref: "#/components/schemas/BatchSummary"

    QrJsonEnvelope:
      type: object
      required: [passportId, gtin, serial, publicUrl, status, qrSvg, qrPngDataUri, colors]
      description: |
        `format=json` response from the QR endpoints. Carries the
        SVG markup, a base64-encoded PNG data URI, and the
        encoded URL + passport status — useful for clients that
        want both renderings in one round-trip plus the metadata
        the QR encodes.
      properties:
        passportId:
          $ref: "#/components/schemas/ObjectId"
        gtin:
          $ref: "#/components/schemas/GTIN"
        serial:
          type: string
        publicUrl:
          type: string
          format: uri
        status:
          $ref: "#/components/schemas/PassportStatus"
        qrSvg:
          type: string
          description: Inline `<svg>` markup, ready to embed.
        qrPngDataUri:
          type: string
          description: "data:image/png;base64,..."
        colors:
          type: object
          properties:
            foreground:
              type: string
            background:
              type: string

    ErrorEnvelope:
      type: object
      required: [error]
      properties:
        error:
          type: string
        details:
          type: object

    OverageRequiredBody:
      type: object
      required: [error]
      properties:
        error:
          type: string
          enum: [overage_required]
        planLimit:
          type: integer
        currentUsage:
          type: integer
        requested:
          type: integer
        extraPriceCents:
          type: integer
        message:
          type: string

    TenantExport:
      type: object
      description: Bulk JSON-LD export envelope.
      properties:
        "@context":
          type: string
          format: uri
        "@type":
          type: string
          enum: [TenantExport]
        exportedAt:
          type: string
          format: date-time
        company:
          type: object
          properties:
            _id:
              $ref: "#/components/schemas/ObjectId"
            name:
              type: string
            country:
              type: string
        counts:
          type: object
          properties:
            products:
              type: integer
            passports:
              type: integer
            templates:
              type: integer
        products:
          type: array
          items:
            $ref: "#/components/schemas/Product"
        passports:
          type: array
          items:
            $ref: "#/components/schemas/Passport"
        templates:
          type: array
          items:
            type: object
            properties:
              id:
                $ref: "#/components/schemas/ObjectId"
              category:
                type: string
              version:
                type: string

  responses:
    BadRequest:
      description: Validation error or malformed request.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    Unauthorized:
      description: Missing or invalid API key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    Forbidden:
      description: Plan-gate or workspace permission denied.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    NotFound:
      description: Resource doesn't exist or is in a different workspace.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    Conflict:
      description: |
        Conflict — duplicate model on a product, GTIN registered
        to a different tenant, serial collision within a GTIN.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    IdempotencyConflict:
      description: |
        Idempotency-Key reused with a different request body. Use
        a new key for the new request, or reuse the original body.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"

    OverageRequired:
      description: |
        DPP quota exhausted; retry with `confirmOverage: true` to
        accept the per-passport charge.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/OverageRequiredBody"

    RateLimit:
      description: |
        Daily v1 write or passport-read budget exhausted. Resets
        at 00:00 UTC.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorEnvelope"
