openapi: 3.1.0

info:
  title: DirtFleet REST API
  version: "1.0.0"
  summary: Public REST + webhook surface for DirtFleet Hours.
  description: |
    Token-authenticated, scoped, JSON. Outbound webhooks signed with
    HMAC-SHA256 over `${timestamp}.${rawBody}` using the subscription
    secret. Receivers verify with the `X-Fleetgo-Signature` and
    `X-Fleetgo-Timestamp` headers (5-minute clock-drift window).

    The endpoint surface grows per integrator demand. If a specific
    surface blocks you, open a support ticket — we prioritize by named
    customer asks.

    CORS: every `/api/v1/*` route accepts cross-origin requests
    (`Access-Control-Allow-Origin: *`) with `Authorization`,
    `Content-Type`, `Idempotency-Key`, and the `X-Fleetgo-*` webhook
    headers permitted. Bearer auth + no cookies means there's no CSRF
    surface to protect. Preflight responses are cached for 24 hours.
  contact:
    name: DirtFleet support
    email: support@dirtfleet.app
    url: https://dirtfleet.app/support
  license:
    name: Proprietary
    url: https://dirtfleet.app/terms

servers:
  - url: https://dirtfleet.app
    description: Production

security:
  - apiKey: []

tags:
  - name: meta
    description: Identity + sanity-check endpoints.
  - name: assets
    description: Trucks, equipment, trailers, anything billable.
  - name: flags
    description: Driver-raised + AUTO_PM repair flags.
  - name: hours
    description: Meter logs against an asset (HOURS / ODOMETER / state-only).
  - name: workOrders
    description: Shop work-order queue with status, priority, due dates.
  - name: tools
    description: Hand tools, power tools, kits, consumables.
  - name: projects
    description: Jobs / cost-allocation targets (P&L rollups, hours filters).
  - name: yards
    description: Physical sites / depots / job-trailer locations where assets are staged.
  - name: webhooks
    description: Outbound HMAC-signed event delivery.

paths:
  /api/v1/me:
    get:
      tags: [meta]
      summary: Identify the org + API key behind this request
      operationId: whoami
      security:
        - apiKey: []
      description: |
        No scope required beyond a valid key. Use this as a "ping" /
        sanity check before touching real data — verifies the bearer
        header is wired up and tells you which scopes the key carries.
      responses:
        "200":
          description: Auth resolved
          content:
            application/json:
              schema:
                type: object
                required: [ok, organization, apiKey]
                properties:
                  ok: { type: boolean, enum: [true] }
                  organization:
                    type: object
                    required: [id, name, slug, createdAt]
                    properties:
                      id: { type: string }
                      name: { type: string }
                      slug: { type: string }
                      createdAt: { type: string, format: date-time }
                  apiKey:
                    type: object
                    required: [id, label, scopes, createdAt]
                    properties:
                      id: { type: string }
                      label: { type: string }
                      scopes:
                        type: array
                        items: { type: string }
                      createdAt: { type: string, format: date-time }
                      lastUsedAt: { type: [string, "null"], format: date-time }
                      expiresAt: { type: [string, "null"], format: date-time }
                      createdBy:
                        type: [object, "null"]
                        properties:
                          id: { type: string }
                          name: { type: [string, "null"] }
                          email: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/assets:
    get:
      tags: [assets]
      summary: List assets (cursor-paginated)
      operationId: listAssets
      security:
        - apiKey: [assets:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
      responses:
        "200":
          description: Page of assets
          content:
            application/json:
              schema:
                type: object
                required: [ok, assets, nextCursor]
                properties:
                  ok:
                    type: boolean
                    enum: [true]
                  assets:
                    type: array
                    items:
                      $ref: "#/components/schemas/Asset"
                  nextCursor:
                    type: [string, "null"]
                    description: Pass back as `cursor=` to fetch the next page; null when done.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [assets]
      summary: Create a new asset
      operationId: createAsset
      security:
        - apiKey: [assets:write]
      description: |
        Required: `nickname` + `assetClass`. Meter type defaults from
        assetClass (on-road → ODOMETER, off-road → HOURS, trailer → NONE)
        so a bare row is usable from creation. Use PATCH on the returned
        id to fill in plates, financials, lifecycle, etc.

        Side effect: triggers a Stripe quantity sync for the org so the
        customer's bill reflects the new billable asset within seconds.
        The sync is fire-and-forget — billing outage won't block creation.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [nickname, assetClass]
              properties:
                nickname: { type: string }
                assetClass:
                  type: string
                  enum: [on-road, off-road, trailer]
                customerAssetNumber: { type: [string, "null"] }
                vin: { type: [string, "null"] }
                serial: { type: [string, "null"] }
            examples:
              minimalTrailer:
                summary: Trailer (no meter)
                value:
                  nickname: Tag-along utility trailer
                  assetClass: trailer
              excavator:
                summary: Off-road equipment with serial
                value:
                  nickname: Excavator 47
                  assetClass: off-road
                  serial: CAT320-12345
                  customerAssetNumber: EX-047
              truck:
                summary: On-road pickup with VIN
                value:
                  nickname: F-150 #4
                  assetClass: on-road
                  vin: 1FTFW1ET5BFA12345
      responses:
        "201":
          description: Asset created
          content:
            application/json:
              schema:
                type: object
                required: [ok, assetId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  assetId: { type: string }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: |
            Missing `nickname` or unsupported `assetClass`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/assets/batch:
    post:
      tags: [assets]
      summary: Bulk-import up to 50 assets in one request
      operationId: createAssetsBatch
      security:
        - apiKey: [assets:write]
      description: |
        Same partial-success contract as `/api/v1/hours/batch`. The cap
        is lower (50) because each create kicks a fire-and-forget Stripe
        quantity sync — 50 in-flight syncs is fine, larger batches start
        to feel adversarial. Bigger fleets: call multiple times.

        Required per entry: `nickname` + `assetClass`. Meter type
        defaults from assetClass on creation (same as the single
        POST). Use PATCH on the returned id to fill in plates,
        financials, etc.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: array
                  items:
                    type: object
                    required: [nickname, assetClass]
                    properties:
                      nickname: { type: string }
                      assetClass:
                        type: string
                        enum: [on-road, off-road, trailer]
                      customerAssetNumber: { type: [string, "null"] }
                      vin: { type: [string, "null"] }
                      serial: { type: [string, "null"] }
                  maxItems: 50
                - type: object
                  required: [entries]
                  properties:
                    entries:
                      type: array
                      maxItems: 50
                      items:
                        type: object
                        required: [nickname, assetClass]
                        properties:
                          nickname: { type: string }
                          assetClass:
                            type: string
                            enum: [on-road, off-road, trailer]
                          customerAssetNumber: { type: [string, "null"] }
                          vin: { type: [string, "null"] }
                          serial: { type: [string, "null"] }
      responses:
        "200":
          description: |
            Batch processed; results[] parallel to input order; summary
            rolls up counts.
          content:
            application/json:
              schema:
                type: object
                required: [ok, results, summary]
                properties:
                  ok: { type: boolean, enum: [true] }
                  results:
                    type: array
                    items:
                      oneOf:
                        - type: object
                          required: [index, ok, assetId]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [true] }
                            assetId: { type: string }
                        - type: object
                          required: [index, ok, error, fieldErrors]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [false] }
                            error:
                              type: string
                              enum: [validation_failed]
                            fieldErrors:
                              type: object
                              additionalProperties: { type: string }
                  summary:
                    type: object
                    required: [received, succeeded, failed]
                    properties:
                      received: { type: integer }
                      succeeded: { type: integer }
                      failed: { type: integer }
        "400":
          description: |
            Envelope rejected: `invalid_envelope`, `empty_batch`, or
            `batch_too_large` (over 50 entries).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/assets/{id}:
    get:
      tags: [assets]
      summary: Single asset by id
      operationId: getAsset
      security:
        - apiKey: [assets:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Asset detail
          content:
            application/json:
              schema:
                type: object
                required: [ok, asset]
                properties:
                  ok: { type: boolean, enum: [true] }
                  asset:
                    $ref: "#/components/schemas/AssetDetail"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: |
            Asset not found in this org. Returned (not 403) for cross-tenant
            ids so we don't leak existence.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

    patch:
      tags: [assets]
      summary: Update a focused subset of asset fields
      operationId: updateAsset
      description: |
        Updatable fields: identity (nickname, customerAssetNumber), plates
        (licensePlate, licenseState), renewals (registrationExpiresAt,
        insuranceExpiresAt), financial summary (purchaseCost, purchaseDate,
        depreciationMethod, usefulLifeYears, currentValue, soldAt, soldPrice,
        soldTo), and lifecycleStatus.

        Meter / PM fields and operator assignments are intentionally **not**
        in this surface. Those flow through specialized in-app paths that
        need richer audit and side-effects (Stripe quantity sync, billable
        recompute) that a bare PATCH would bypass.
      security:
        - apiKey: [assets:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                nickname:
                  type: string
                  description: Cannot be blank.
                customerAssetNumber: { type: [string, "null"] }
                licensePlate: { type: [string, "null"] }
                licenseState: { type: [string, "null"] }
                registrationExpiresAt:
                  type: [string, "null"]
                  format: date-time
                insuranceExpiresAt:
                  type: [string, "null"]
                  format: date-time
                purchaseCost: { type: [number, "null"] }
                purchaseDate: { type: [string, "null"], format: date-time }
                depreciationMethod:
                  type: string
                  enum: [NONE, STRAIGHT_LINE, DECLINING_BALANCE]
                usefulLifeYears: { type: [integer, "null"] }
                currentValue: { type: [number, "null"] }
                lifecycleStatus:
                  type: string
                  enum: [IN_SERVICE, OUT_OF_SERVICE, ARCHIVED, SOLD]
                soldAt: { type: [string, "null"], format: date-time }
                soldPrice: { type: [number, "null"] }
                soldTo: { type: [string, "null"] }
            examples:
              renewPlate:
                summary: Renew the registration
                value:
                  registrationExpiresAt: "2027-05-01T00:00:00Z"
              markSold:
                summary: Mark sold + record disposition
                value:
                  lifecycleStatus: SOLD
                  soldAt: "2026-05-14T00:00:00Z"
                  soldPrice: 42000
                  soldTo: Acme Equipment Auctions
              clearPlate:
                summary: Clear a plate (pass null)
                value:
                  licensePlate: null
                  licenseState: null
      responses:
        "200":
          description: Asset updated
          content:
            application/json:
              schema:
                type: object
                required: [ok, assetId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  assetId: { type: string }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Asset not found in this org.
        "422":
          description: |
            Empty patch body, or `nickname` set to a blank string.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/flags:
    get:
      tags: [flags]
      summary: List flags (cursor-paginated, defaults to OPEN)
      operationId: listFlags
      security:
        - apiKey: [flags:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: severity
          in: query
          required: false
          description: CSV of severities to include. Unknown values are ignored.
          schema:
            type: string
            example: RED,YELLOW
        - name: status
          in: query
          required: false
          description: CSV of statuses to include. Defaults to `OPEN`.
          schema:
            type: string
            example: OPEN,IN_PROGRESS
        - name: assetId
          in: query
          required: false
          description: Restrict to flags raised against a single asset.
          schema:
            type: string
      responses:
        "200":
          description: Page of flags
          content:
            application/json:
              schema:
                type: object
                required: [ok, flags, nextCursor]
                properties:
                  ok:
                    type: boolean
                    enum: [true]
                  flags:
                    type: array
                    items:
                      $ref: "#/components/schemas/Flag"
                  nextCursor:
                    type: [string, "null"]
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [flags]
      summary: Raise a new flag against an asset
      operationId: createFlag
      security:
        - apiKey: [flags:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [assetId, severity]
              properties:
                assetId: { type: string }
                severity:
                  type: string
                  enum: [YELLOW, RED]
                  description: |
                    AUTO_PM is reserved for the engine; only YELLOW + RED
                    are user-raisable.
                note:
                  type: [string, "null"]
                  maxLength: 500
                geo:
                  type: [object, "null"]
                  properties:
                    lat: { type: number, minimum: -90, maximum: 90 }
                    lng: { type: number, minimum: -180, maximum: 180 }
                    accuracyM: { type: number, minimum: 0, maximum: 100000 }
            examples:
              red:
                summary: Red flag — driver-raised, immediate
                value:
                  assetId: clx7asset_excavator
                  severity: RED
                  note: Hydraulic leak — flagged from cab
              yellow:
                summary: Yellow flag — non-blocking
                value:
                  assetId: clx7asset_loader
                  severity: YELLOW
                  note: Cab door rattle, low priority
      responses:
        "201":
          description: Flag created; push + email notifications fire async
          content:
            application/json:
              schema:
                type: object
                required: [ok, flagId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  flagId: { type: string }
        "400":
          description: Body could not be parsed as JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Insufficient scope, or `key_creator_deleted` if the API key's
            issuing user has been removed.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: Validation failed; see `fieldErrors`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/work-orders/{id}:
    get:
      tags: [workOrders]
      summary: Single work order by id
      operationId: getWorkOrder
      security:
        - apiKey: [workorders:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Work-order detail
          content:
            application/json:
              schema:
                type: object
                required: [ok, workOrder]
                properties:
                  ok: { type: boolean, enum: [true] }
                  workOrder:
                    $ref: "#/components/schemas/WorkOrder"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Work order not found in this org.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          $ref: "#/components/responses/RateLimited"

    patch:
      tags: [workOrders]
      summary: Update a work order (status, priority, assignment, fields)
      operationId: updateWorkOrder
      security:
        - apiKey: [workorders:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        description: |
          All fields are optional. Omitted fields stay unchanged. Pass `null`
          for nullable fields to clear them (e.g. unassign with
          `assignedToId: null`). Unknown enum values are silently dropped
          rather than 422'ing — forgiving for first-time integrators.
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                description: { type: [string, "null"] }
                status:
                  type: string
                  enum: [OPEN, ASSIGNED, IN_PROGRESS, ON_HOLD, CANCELED, DONE]
                priority:
                  type: string
                  enum: [LOW, NORMAL, HIGH, URGENT]
                assignedToId: { type: [string, "null"] }
                assetId: { type: [string, "null"] }
                projectId: { type: [string, "null"] }
                dueAt: { type: [string, "null"], format: date-time }
                estimateLaborHours: { type: [number, "null"] }
                estimateCost: { type: [number, "null"] }
            examples:
              assignAndStart:
                summary: Assign + flip status to IN_PROGRESS
                value:
                  assignedToId: clx7mech_jordan
                  status: IN_PROGRESS
              bumpPriority:
                summary: Raise to URGENT
                value:
                  priority: URGENT
              markDone:
                summary: Close the WO
                value:
                  status: DONE
              unassign:
                summary: Drop the assigned mechanic
                value:
                  assignedToId: null
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [ok, workOrderId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  workOrderId: { type: string }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Insufficient scope or `key_creator_deleted`.
        "404":
          description: Work order not found in this org.
        "422":
          description: |
            Referenced asset / project belongs to another org, or the
            update validation otherwise failed.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools:
    get:
      tags: [tools]
      summary: List tools (cursor-paginated, defaults to active inventory)
      operationId: listTools
      security:
        - apiKey: [tools:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          required: false
          description: |
            CSV. Defaults to active inventory
            (GOOD,NEEDS_ATTENTION,BROKEN,MISSING — excludes RETIRED).
            Pass explicit values to widen.
          schema: { type: string, example: GOOD,NEEDS_ATTENTION }
        - name: category
          in: query
          required: false
          schema: { type: string, example: wrench }
        - name: assignedUserId
          in: query
          required: false
          schema: { type: string }
        - name: assignedVehicleId
          in: query
          required: false
          schema: { type: string }
        - name: parentKitId
          in: query
          required: false
          description: Restrict to children of a single kit/set.
          schema: { type: string }
        - name: lowStock
          in: query
          required: false
          description: |
            When `true`, filters to consumables that need reordering —
            `isConsumable=true` AND `stockLevel <= COALESCE(minStockLevel, 5)`.
            DirtFleet pre-filters at the SQL layer to isConsumable=true with a
            non-null stockLevel, then completes the threshold comparison in
            application code. The page-size guarantee at the cursor is therefore
            slightly looser than the unfiltered list — typically the surface is
            small by design.
          schema: { type: boolean }
        - name: pmOverdue
          in: query
          required: false
          description: |
            When `true`, filters to tools whose preventative-maintenance
            interval has elapsed — `pmIntervalDays > 0` AND
            `(now - COALESCE(lastServicedAt, createdAt)) >= pmIntervalDays`.
            Same SQL-prefilter + JS-postfilter pattern as `lowStock`.
            Composes with `lowStock`: passing both narrows to tools that
            are both consumables AND have an overdue PM (rare in practice,
            but the combination resolves cleanly).
          schema: { type: boolean }
        - name: warrantyExpiringDays
          in: query
          required: false
          description: |
            When set, filters to tools whose `warrantyEndDate` falls
            within the next N days (`warrantyEndDate >= now AND
            warrantyEndDate <= now + Nd`). Excludes already-expired
            warranties and tools with no `warrantyEndDate` set.
            Accepts 1-365; values outside that range are silently
            ignored. Use for warranty-renewal scheduling and budget
            forecasting.
          schema:
            type: integer
            minimum: 1
            maximum: 365
        - name: format
          in: query
          required: false
          description: |
            Response format. Default `json`. Pass `csv` to receive a
            spreadsheet-ready download — same filter surface (status,
            category, assignedUserId, lowStock, pmOverdue,
            warrantyExpiringDays) applies. The
            `Content-Type` switches to `text/csv; charset=utf-8` and
            a `Content-Disposition: attachment` header is set with a
            dated filename (e.g. `dirtfleet-tools-2026-05-14.csv`).
            The CSV column order is stable; check the file header for
            the canonical names.
          schema:
            type: string
            enum: [json, csv]
            default: json
      responses:
        "200":
          description: |
            Page of tools (ordered by name asc) when format=json, or a
            spreadsheet download when format=csv (filename
            `dirtfleet-tools-YYYY-MM-DD.csv`, content-type
            `text/csv; charset=utf-8`).
          content:
            application/json:
              schema:
                type: object
                required: [ok, tools, nextCursor]
                properties:
                  ok: { type: boolean, enum: [true] }
                  tools:
                    type: array
                    items:
                      $ref: "#/components/schemas/Tool"
                  nextCursor: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [tools]
      summary: Create a new tool
      operationId: createTool
      security:
        - apiKey: [tools:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                category: { type: [string, "null"] }
                makeModel: { type: [string, "null"] }
                serial: { type: [string, "null"] }
                purchaseDate: { type: [string, "null"], format: date-time }
                purchaseCost: { type: [number, "null"] }
                supplier: { type: [string, "null"] }
                warrantyEndDate:
                  type: [string, "null"]
                  format: date-time
                isConsumable: { type: boolean, default: false }
                stockLevel:
                  type: [integer, "null"]
                  description: For consumables. Ignored on non-consumable tools.
                minStockLevel:
                  type: [integer, "null"]
                  description: |
                    Reorder threshold for `?lowStock=true`. Null = use the
                    org-wide fallback (5).
                notes: { type: [string, "null"] }
                parentKitId: { type: [string, "null"] }
                assignedUserId: { type: [string, "null"] }
                assignedVehicleId: { type: [string, "null"] }
                assignedYardId: { type: [string, "null"] }
                pmIntervalDays: { type: [integer, "null"] }
      responses:
        "201":
          description: Tool created
          content:
            application/json:
              schema:
                type: object
                required: [ok, toolId, scanToken]
                properties:
                  ok: { type: boolean, enum: [true] }
                  toolId: { type: string }
                  scanToken:
                    type: string
                    description: |
                      The token embedded in this tool's QR/NFC label.
                      Use it to render the printable label or generate
                      a deep link at /scan/{token}.
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: |
            Missing name, referenced parent kit / vehicle / user /
            yard belongs to another org, or other validation rejection.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/projects/batch:
    post:
      tags: [projects]
      summary: Bulk-import up to 50 projects in one request
      operationId: createProjectsBatch
      security:
        - apiKey: [projects:write]
      description: |
        Partial-success semantics matching /hours/batch and
        /assets/batch. Each entry's `code` is unique per org; the
        underlying lib path surfaces P2002 collisions as a per-entry
        validation failure (the rest of the batch still succeeds).
        Cap: 50 entries — bigger batches call multiple times.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: array
                  maxItems: 50
                  items:
                    type: object
                    required: [name]
                    properties:
                      name: { type: string }
                      code: { type: [string, "null"] }
                      customerName: { type: [string, "null"] }
                      status:
                        type: string
                        enum: [ACTIVE, ON_HOLD, COMPLETED, ARCHIVED]
                      startDate:
                        type: [string, "null"]
                        format: date-time
                      endDate:
                        type: [string, "null"]
                        format: date-time
                      hourlyRate: { type: [number, "null"] }
                      notes: { type: [string, "null"] }
                - type: object
                  required: [entries]
                  properties:
                    entries:
                      type: array
                      maxItems: 50
      responses:
        "200":
          description: Batch processed
          content:
            application/json:
              schema:
                type: object
                required: [ok, results, summary]
                properties:
                  ok: { type: boolean, enum: [true] }
                  results:
                    type: array
                    items:
                      oneOf:
                        - type: object
                          required: [index, ok, projectId]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [true] }
                            projectId: { type: string }
                        - type: object
                          required: [index, ok, error]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [false] }
                            error: { type: string, enum: [validation_failed] }
                            formError: { type: [string, "null"] }
                            fieldErrors:
                              type: object
                              additionalProperties: { type: string }
                  summary:
                    type: object
                    required: [received, succeeded, failed]
                    properties:
                      received: { type: integer }
                      succeeded: { type: integer }
                      failed: { type: integer }
        "400":
          description: Envelope rejected (not an array, empty, over 50).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/batch:
    post:
      tags: [tools]
      summary: Bulk-import up to 100 tools in one request
      operationId: createToolsBatch
      security:
        - apiKey: [tools:write]
      description: |
        Partial-success semantics matching /hours/batch and the
        single-resource POST /tools. Each successful entry returns its
        new id plus the auto-minted `scanToken` (for QR/NFC labels) so
        the receiver can render the printable batch immediately. Cap:
        100 entries.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: array
                  maxItems: 100
                  items:
                    type: object
                    required: [name]
                    properties:
                      name: { type: string }
                      category: { type: [string, "null"] }
                      makeModel: { type: [string, "null"] }
                      serial: { type: [string, "null"] }
                      isConsumable: { type: boolean }
                      stockLevel: { type: [integer, "null"] }
                      purchaseDate:
                        type: [string, "null"]
                        format: date-time
                      purchaseCost: { type: [number, "null"] }
                      supplier: { type: [string, "null"] }
                      warrantyEndDate:
                        type: [string, "null"]
                        format: date-time
                      notes: { type: [string, "null"] }
                      parentKitId: { type: [string, "null"] }
                      assignedUserId: { type: [string, "null"] }
                      assignedVehicleId: { type: [string, "null"] }
                      assignedYardId: { type: [string, "null"] }
                      pmIntervalDays: { type: [integer, "null"] }
                - type: object
                  required: [entries]
                  properties:
                    entries:
                      type: array
                      maxItems: 100
      responses:
        "200":
          description: Batch processed
          content:
            application/json:
              schema:
                type: object
                required: [ok, results, summary]
                properties:
                  ok: { type: boolean, enum: [true] }
                  results:
                    type: array
                    items:
                      oneOf:
                        - type: object
                          required: [index, ok, toolId, scanToken]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [true] }
                            toolId: { type: string }
                            scanToken: { type: string }
                        - type: object
                          required: [index, ok, error]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [false] }
                            error: { type: string, enum: [validation_failed] }
                            formError: { type: [string, "null"] }
                            fieldErrors:
                              type: object
                              additionalProperties: { type: string }
                  summary:
                    type: object
                    required: [received, succeeded, failed]
                    properties:
                      received: { type: integer }
                      succeeded: { type: integer }
                      failed: { type: integer }
        "400":
          description: Envelope rejected (not an array, empty, over 100).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/projects:
    get:
      tags: [projects]
      summary: List projects (cursor-paginated, defaults to ACTIVE)
      operationId: listProjects
      security:
        - apiKey: [projects:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          required: false
          description: |
            CSV of ACTIVE, ON_HOLD, COMPLETED, ARCHIVED. Defaults to
            ACTIVE — pass explicit values to widen.
          schema: { type: string, example: ACTIVE,ON_HOLD }
      responses:
        "200":
          description: Page of projects
          content:
            application/json:
              schema:
                type: object
                required: [ok, projects, nextCursor]
                properties:
                  ok: { type: boolean, enum: [true] }
                  projects:
                    type: array
                    items:
                      $ref: "#/components/schemas/Project"
                  nextCursor: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [projects]
      summary: Create a new project
      operationId: createProject
      security:
        - apiKey: [projects:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                code:
                  type: [string, "null"]
                  description: Short reference / job number ("J-2026-014"). Unique per org.
                customerName: { type: [string, "null"] }
                status:
                  type: string
                  enum: [ACTIVE, ON_HOLD, COMPLETED, ARCHIVED]
                  default: ACTIVE
                startDate: { type: [string, "null"], format: date-time }
                endDate: { type: [string, "null"], format: date-time }
                hourlyRate: { type: [number, "null"] }
                notes: { type: [string, "null"] }
      responses:
        "201":
          description: Project created
          content:
            application/json:
              schema:
                type: object
                required: [ok, projectId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  projectId: { type: string }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: |
            Missing name, duplicate `code` in this org, hourlyRate out of
            range, or other validation rejection.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/{id}:
    get:
      tags: [tools]
      summary: Single tool by id
      operationId: getTool
      security:
        - apiKey: [tools:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Tool detail (includes scanToken for label printing)
          content:
            application/json:
              schema:
                type: object
                required: [ok, tool]
                properties:
                  ok: { type: boolean, enum: [true] }
                  tool:
                    allOf:
                      - $ref: "#/components/schemas/Tool"
                      - type: object
                        properties:
                          scanToken:
                            type: string
                            description: |
                              Token embedded in the tool's QR/NFC label.
                              Resolves at /scan/{token}.
                          supplier: { type: [string, "null"] }
                          notes: { type: [string, "null"] }
                          pmIntervalDays: { type: [integer, "null"] }
                          assignedYardId: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

    patch:
      tags: [tools]
      summary: Update a tool (status, assignment, fields)
      operationId: updateTool
      security:
        - apiKey: [tools:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        description: |
          All fields optional. Omitted fields stay unchanged; pass `null`
          to clear nullable fields (unassign with `assignedUserId: null`).

          Side effects: when any of `assignedUserId`, `assignedVehicleId`,
          `assignedYardId`, or `parentKitId` actually changes, the PATCH
          emits an `ASSIGNMENT` ToolEvent (queryable via
          `/tools/{id}/events?kind=ASSIGNMENT`) **and** fires a single
          `tool.assignment_changed` webhook whose `changes` array
          aggregates every kind that moved in that one PATCH.
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                category: { type: [string, "null"] }
                makeModel: { type: [string, "null"] }
                serial: { type: [string, "null"] }
                status:
                  type: string
                  enum: [GOOD, NEEDS_ATTENTION, BROKEN, MISSING, RETIRED]
                isConsumable: { type: boolean }
                stockLevel: { type: [integer, "null"] }
                minStockLevel: { type: [integer, "null"] }
                notes: { type: [string, "null"] }
                parentKitId: { type: [string, "null"] }
                assignedUserId: { type: [string, "null"] }
                assignedVehicleId: { type: [string, "null"] }
                assignedYardId: { type: [string, "null"] }
                purchaseDate: { type: [string, "null"], format: date-time }
                purchaseCost: { type: [number, "null"] }
                supplier: { type: [string, "null"] }
                warrantyEndDate: { type: [string, "null"], format: date-time }
                pmIntervalDays: { type: [integer, "null"] }
      responses:
        "200":
          description: Tool updated
          content:
            application/json:
              schema:
                type: object
                required: [ok, toolId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  toolId: { type: string }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org.
        "422":
          description: Validation failed
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/{id}/adjust-stock:
    post:
      tags: [tools]
      summary: Adjust the stockLevel of a consumable tool (used / restocked)
      description: |
        Records an AUDIT ToolEvent and updates the tool's `stockLevel`
        by the signed `delta`. Positive = restock, negative = usage.
        Consumables only (`isConsumable: true`); returns 409 on non-
        consumable tools because they use the check-out / check-in
        flow instead.

        When the adjustment causes a crossing from above-threshold to
        at-or-below-threshold (`stockLevel <= COALESCE(minStockLevel, 5)`),
        a `tool.low_stock` webhook fires automatically — subscribers
        can wire it to reorder automation, Slack, or a daily summary.
        Going further below threshold or restocking back up does NOT
        retrigger the event.

        Resulting `stockLevel` is clamped to zero — you can't go below
        zero on hand.
      operationId: adjustToolStock
      security:
        - apiKey: [tools:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [delta]
              properties:
                delta:
                  type: integer
                  description: |
                    Non-zero signed change. Negative = used N;
                    positive = restocked N. Non-integer values are
                    truncated to integer.
                note:
                  type: [string, "null"]
                  description: |
                    Free-form context appended to the event's
                    structured `stock_adjusted: delta=N new_level=M`
                    description prefix. ≤200 chars.
      responses:
        "201":
          description: Stock adjusted
          content:
            application/json:
              schema:
                type: object
                required:
                  - ok
                  - toolId
                  - eventId
                  - previousStockLevel
                  - newStockLevel
                  - crossedThreshold
                properties:
                  ok: { type: boolean, enum: [true] }
                  toolId: { type: string }
                  eventId: { type: string }
                  previousStockLevel: { type: integer }
                  newStockLevel: { type: integer }
                  crossedThreshold:
                    type: boolean
                    description: True iff this call fired tool.low_stock.
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org.
        "409":
          description: Tool is not consumable — use checkout / checkin instead.
        "422":
          description: Missing or invalid delta.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/{id}/mark-serviced:
    post:
      tags: [tools]
      summary: Record that a tool's PM service was completed
      description: |
        Updates the tool's `lastServicedAt` to now (or the optional
        `at` body field) and creates a structured AUDIT ToolEvent with
        a `pm_serviced:` prefix. After this call, the tool no longer
        appears in `GET /tools?pmOverdue=true` until the next
        pmIntervalDays elapses.

        Returns 422 when the tool has no `pmIntervalDays` set —
        recording a service on a tool with no PM cadence is almost
        always a mistake; set `pmIntervalDays` via PATCH first.

        Side effects: emits a `tool.serviced` webhook with the full
        before/after `lastServicedAt`, the actor, the interval, and
        any note. Best-effort delivery (won't fail the service record
        if enqueue rejects).
      operationId: markToolServiced
      security:
        - apiKey: [tools:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                at:
                  type: string
                  format: date-time
                  description: |
                    Optional. ISO date-time when the service actually
                    happened — useful for back-dating historical
                    records during onboarding. Defaults to the current
                    server time.
                note:
                  type: [string, "null"]
                  description: |
                    Free-form context appended to the structured
                    description prefix. Truncated to 500 chars.
      responses:
        "201":
          description: Service recorded.
          content:
            application/json:
              schema:
                type: object
                required:
                  - ok
                  - toolId
                  - eventId
                  - previousLastServicedAt
                  - newLastServicedAt
                properties:
                  ok: { type: boolean, enum: [true] }
                  toolId: { type: string }
                  eventId: { type: string }
                  previousLastServicedAt:
                    type: [string, "null"]
                    format: date-time
                    description: |
                      Null when the tool was being serviced for the
                      first time.
                  newLastServicedAt:
                    type: string
                    format: date-time
        "400":
          description: Body could not be parsed as JSON.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org.
        "422":
          description: |
            Either `pmIntervalDays` is not set on the tool, or `at`
            is not a valid ISO date-time.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/{id}/events:
    get:
      tags: [tools]
      summary: Paginated audit-event feed for one tool
      description: |
        Cursor-paginated tool audit trail. Use this to mirror tool
        history into accounting / CMMS systems without paging through
        the full /tools/{id} detail body on every read. Cross-tenant
        safe (404 on a tool the org doesn't own).

        The `description` field on AUDIT-kind events uses structured
        prefixes set by the typed endpoints:
        `stock_adjusted: delta=N new_level=M note=...` (from
        POST /adjust-stock),
        `pm_serviced: interval=Nd serviced_at=ISO note=...` (from
        POST /mark-serviced),
        `tool_created: name=... category=... consumable=true` (the
        oldest event on every tool — emitted by POST /tools and the
        bulk path; the suffix omits the category= and consumable=
        segments when unset), and
        `status_changed: from=X to=Y` (emitted by PATCH /tools/{id}
        when the status enum actually moves — captures retirement,
        missing, and needs-attention transitions). The CHECK_OUT /
        CHECK_IN / FAILURE_REPORT kinds use free-form `description`.
      operationId: listToolEvents
      security:
        - apiKey: [tools:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: kind
          in: query
          required: false
          description: |
            CSV of ToolEventKind values (CHECK_OUT, CHECK_IN, ASSIGNMENT,
            CONDITION_REPORT, FAILURE_REPORT, REPLACEMENT_REQUEST,
            REPAIR_LOGGED, AUDIT). Unknown values are dropped silently.
          schema: { type: string }
        - name: since
          in: query
          required: false
          description: ISO date-time floor for createdAt.
          schema: { type: string, format: date-time }
        - name: actorId
          in: query
          required: false
          description: |
            Filter to events recorded by this user id. Useful for
            audit queries like "what has this mechanic done with the
            tool?"
          schema: { type: string }
        - name: linkedAssetId
          in: query
          required: false
          description: |
            Filter to events that touched this asset via
            ToolEvent.linkedAssetId (CHECK_OUT to a job-linked asset,
            etc.). Useful for "every tool event that touched the John
            Deere 320 this quarter."
          schema: { type: string }
        - name: cursor
          in: query
          required: false
          description: Event id from the previous page's nextCursor.
          schema: { type: string }
        - name: limit
          in: query
          required: false
          description: Page size, clamped to [1, 200]. Default 50.
          schema:
            type: integer
            default: 50
            minimum: 1
            maximum: 200
      responses:
        "200":
          description: Page of events, ordered createdAt desc
          content:
            application/json:
              schema:
                type: object
                required: [ok, toolId, events, nextCursor]
                properties:
                  ok: { type: boolean, enum: [true] }
                  toolId: { type: string }
                  events:
                    type: array
                    items:
                      type: object
                      required: [id, kind, createdAt]
                      properties:
                        id: { type: string }
                        kind:
                          type: string
                          enum:
                            - CHECK_OUT
                            - CHECK_IN
                            - ASSIGNMENT
                            - CONDITION_REPORT
                            - FAILURE_REPORT
                            - REPLACEMENT_REQUEST
                            - REPAIR_LOGGED
                            - AUDIT
                        condition: { type: [string, "null"] }
                        description: { type: [string, "null"] }
                        cost: { type: [number, "null"] }
                        linkedAssetId: { type: [string, "null"] }
                        linkedWorkOrderId: { type: [string, "null"] }
                        photoId: { type: [string, "null"] }
                        actor:
                          oneOf:
                            - type: "null"
                            - type: object
                              required: [id]
                              properties:
                                id: { type: string }
                                name: { type: [string, "null"] }
                                email: { type: [string, "null"] }
                        createdAt: { type: string, format: date-time }
                  nextCursor: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/{id}/utilization:
    get:
      tags: [tools]
      summary: Checkout utilization for one tool over a recent window
      description: |
        Pairs CHECK_OUT events with the next CHECK_IN and sums the
        durations to compute how much of the window the tool was in use.
        Currently-open checkouts (no CHECK_IN yet) are closed at `now`
        and `openAtWindowEnd` becomes `true`. Checkouts that started
        before the window are clamped to the window start, so a checkout
        straddling the edge contributes only the inside portion.

        Returns a top-N user breakdown sorted by total checkout time
        (`topUsers` array; `topN` query param controls N).
      operationId: getToolUtilization
      security:
        - apiKey: [tools:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: days
          in: query
          required: false
          description: Window size in days. Default 30. Clamped to [1, 365].
          schema:
            type: integer
            default: 30
            minimum: 1
            maximum: 365
        - name: topN
          in: query
          required: false
          description: |
            Number of top users to include in `topUsers`. Default 3.
            Clamped to [0, 10]; pass 0 to omit the per-user breakdown
            entirely.
          schema:
            type: integer
            default: 3
            minimum: 0
            maximum: 10
      responses:
        "200":
          description: Utilization for the requested window
          content:
            application/json:
              schema:
                type: object
                required: [ok, utilization]
                properties:
                  ok: { type: boolean, enum: [true] }
                  utilization:
                    type: object
                    required:
                      - toolId
                      - windowStart
                      - windowEnd
                      - windowMs
                      - checkedOutMs
                      - checkoutCount
                      - openAtWindowEnd
                      - utilizationPct
                      - topUsers
                    properties:
                      toolId: { type: string }
                      windowStart: { type: string, format: date-time }
                      windowEnd: { type: string, format: date-time }
                      windowMs: { type: integer }
                      checkedOutMs:
                        type: integer
                        description: Total milliseconds the tool was checked out inside the window.
                      checkoutCount:
                        type: integer
                        description: Number of distinct CHECK_OUT events inside the window.
                      openAtWindowEnd:
                        type: boolean
                        description: True when the tool is currently checked out at windowEnd.
                      utilizationPct:
                        type: number
                        minimum: 0
                        maximum: 1
                        description: checkedOutMs / windowMs, clamped to [0, 1].
                      topUsers:
                        type: array
                        items:
                          type: object
                          required: [userId, checkedOutMs, checkoutCount]
                          properties:
                            userId: { type: string }
                            name: { type: [string, "null"] }
                            email: { type: [string, "null"] }
                            checkedOutMs: { type: integer }
                            checkoutCount: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/{id}/qr:
    get:
      tags: [tools]
      summary: Print-ready QR code for a tool's scan token
      description: |
        Returns a PNG (or SVG with `?fmt=svg`) of a QR code encoding
        a deep-link to `/scan/{scanToken}` — when a phone scans it,
        the user lands on the tool's report screen. Error correction
        is Level H so the code stays scannable with up to ~30% of
        the surface obscured (dirt, scratches, partial damage).

        Designed for label-printing integrations: pull the tools
        list, loop, download each PNG, send to the Zebra / Brother /
        Dymo. Default 600px width prints clean at 1.5-2 inches on a
        300-DPI label.
      operationId: getToolQrCode
      security:
        - apiKey: [tools:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: fmt
          in: query
          required: false
          description: "`png` (default) or `svg`."
          schema:
            type: string
            enum: [png, svg]
            default: png
        - name: size
          in: query
          required: false
          description: |
            Output width in pixels, clamped to 120-2000. Default 600.
            For PNG this is the raster dimension; for SVG it sets
            the viewport / width attribute.
          schema:
            type: integer
            minimum: 120
            maximum: 2000
            default: 600
      responses:
        "200":
          description: QR image bytes.
          content:
            image/png:
              schema:
                type: string
                format: binary
            image/svg+xml:
              schema:
                type: string
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/{id}/checkout:
    post:
      tags: [tools]
      summary: Check a tool out to a user / vehicle
      description: |
        Records a `CHECK_OUT` ToolEvent and stamps the tool's
        `checkedOutToUserId`, `checkedOutToVehicleId`, and
        `checkedOutAt` fields. The API-key creator is recorded as the
        actor on the event.

        Idempotent per (toolId, target user): re-posting for the same
        target user is a no-op success — useful when the integration
        retries on partial network failure. Returns `409 conflict` when
        the tool is already checked out to someone else, unless
        `force: true` overrides the existing checkout.

        Returns `409` when the tool is `RETIRED` or `MISSING` — resolve
        those states before attempting check-out.
      operationId: checkoutTool
      security:
        - apiKey: [tools:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: false
        description: |
          All fields optional. When `toUserId` is omitted, the tool is
          checked out to the API-key creator (audit trail still shows
          who minted the key).
        content:
          application/json:
            schema:
              type: object
              properties:
                toUserId:
                  type: [string, "null"]
                  description: User to check the tool out to.
                toVehicleId:
                  type: [string, "null"]
                  description: Vehicle the tool is going onto.
                linkedAssetId:
                  type: [string, "null"]
                  description: Job / piece of equipment the tool is supporting.
                note:
                  type: [string, "null"]
                  description: Free-form, truncated to 1000 chars.
                force:
                  type: boolean
                  description: Override an existing checkout to another user.
                includeChildren:
                  type: boolean
                  description: |
                    When true and the tool is a kit (has children with
                    `parentKitId` pointing at it), check out the kit and
                    every child in one call. Each child gets its own
                    `CHECK_OUT` ToolEvent + `tool.checked_out` webhook.
                    Per-child failures (already-out, RETIRED, MISSING)
                    land in `skipped[]` without aborting the batch.
                    Response shape becomes
                    `{ ok, kitId, succeeded[], skipped[] }` instead of
                    `{ ok, toolId, eventId }`.
      responses:
        "201":
          description: Tool checked out
          content:
            application/json:
              schema:
                type: object
                required: [ok, toolId, eventId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  toolId: { type: string }
                  eventId: { type: string }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org (or linkedAssetId unknown).
        "409":
          description: Tool already checked out to another user (use `force: true` to override) or tool is RETIRED / MISSING.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/{id}/checkin:
    post:
      tags: [tools]
      summary: Check a tool back in (with condition)
      description: |
        Records a `CHECK_IN` ToolEvent, clears the active checkout
        fields, and downgrades the tool's status based on the observed
        condition:

        | condition | resulting status | side effect |
        |---|---|---|
        | `GOOD` | `GOOD` | none |
        | `NEEDS_ATTENTION` | `NEEDS_ATTENTION` | none |
        | `BROKEN` | `BROKEN` | auto-spawns a HIGH-priority WorkOrder |

        When `BROKEN`, the response includes the new `workOrderId` so
        the integration can deep-link the shop into the spawned WO.
      operationId: checkinTool
      security:
        - apiKey: [tools:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [condition]
              properties:
                condition:
                  type: string
                  enum: [GOOD, NEEDS_ATTENTION, BROKEN]
                note:
                  type: [string, "null"]
                  description: Free-form, truncated to 1000 chars.
                photoId:
                  type: [string, "null"]
                  description: Optional Photo id for evidence.
                includeChildren:
                  type: boolean
                  description: |
                    When true and the tool is a kit, check in the kit
                    and every child in one call. Each child gets its
                    own `CHECK_IN` event + `tool.checked_in` webhook.
                    A BROKEN check-in still auto-spawns one WorkOrder
                    per child. Response becomes
                    `{ ok, kitId, succeeded[], skipped[] }`.
      responses:
        "201":
          description: Tool checked in
          content:
            application/json:
              schema:
                type: object
                required: [ok, toolId, eventId, workOrderId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  toolId: { type: string }
                  eventId: { type: string }
                  workOrderId:
                    type: [string, "null"]
                    description: Non-null only when `condition: BROKEN`.
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org.
        "422":
          description: Missing or invalid `condition` value.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/tools/{id}/report:
    post:
      tags: [tools]
      summary: Report a tool problem (failure or replacement)
      description: |
        Marks the tool `BROKEN` (or `MISSING` when `markMissing: true`)
        and auto-spawns a WorkOrder so the shop sees it in the queue.

        | kind | priority | title |
        |---|---|---|
        | `FAILURE_REPORT` (default) | `HIGH` | `Repair tool: <name>` |
        | `REPLACEMENT_REQUEST` | `NORMAL` | `Replace tool: <name>` |
        | (any) + `markMissing: true` | `HIGH` | `Locate / replace missing tool: <name>` |
      operationId: reportToolIssue
      security:
        - apiKey: [tools:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [description]
              properties:
                description:
                  type: string
                  description: Free-form, truncated to 1000 chars.
                kind:
                  type: string
                  enum: [FAILURE_REPORT, REPLACEMENT_REQUEST]
                  default: FAILURE_REPORT
                markMissing:
                  type: boolean
                  description: True = mark the tool MISSING instead of BROKEN.
                cost:
                  type: [number, "null"]
                  description: Estimated repair / replacement cost (USD).
                photoId:
                  type: [string, "null"]
                  description: Optional Photo id for evidence.
      responses:
        "201":
          description: Issue recorded + work order spawned
          content:
            application/json:
              schema:
                type: object
                required: [ok, toolId, eventId, workOrderId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  toolId: { type: string }
                  eventId: { type: string }
                  workOrderId: { type: [string, "null"] }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Tool not found in this org.
        "422":
          description: Missing or empty `description`.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/projects/{id}:
    get:
      tags: [projects]
      summary: Single project by id
      operationId: getProject
      security:
        - apiKey: [projects:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Project detail
          content:
            application/json:
              schema:
                type: object
                required: [ok, project]
                properties:
                  ok: { type: boolean, enum: [true] }
                  project:
                    $ref: "#/components/schemas/Project"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Project not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

    patch:
      tags: [projects]
      summary: Update a project (status, dates, rate, fields)
      operationId: updateProject
      security:
        - apiKey: [projects:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                code: { type: [string, "null"] }
                customerName: { type: [string, "null"] }
                status:
                  type: string
                  enum: [ACTIVE, ON_HOLD, COMPLETED, ARCHIVED]
                startDate: { type: [string, "null"], format: date-time }
                endDate: { type: [string, "null"], format: date-time }
                hourlyRate: { type: [number, "null"] }
                notes: { type: [string, "null"] }
      responses:
        "200":
          description: Project updated
          content:
            application/json:
              schema:
                type: object
                required: [ok, projectId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  projectId: { type: string }
        "400":
          description: |
            Either malformed JSON, or the new `code` collides with an
            existing project's code in this org (lib returns code:400 in
            that case to distinguish from generic 422).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Project not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/yards:
    get:
      tags: [yards]
      summary: List yards in this org
      operationId: listYards
      security:
        - apiKey: [yards:read]
      responses:
        "200":
          description: All yards, ordered by name
          content:
            application/json:
              schema:
                type: object
                required: [ok, yards]
                properties:
                  ok: { type: boolean, enum: [true] }
                  yards:
                    type: array
                    items:
                      $ref: "#/components/schemas/Yard"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [yards]
      summary: Create a new yard
      operationId: createYard
      security:
        - apiKey: [yards:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Unique within the org.
                lat:
                  type: [number, "null"]
                  minimum: -90
                  maximum: 90
                lng:
                  type: [number, "null"]
                  minimum: -180
                  maximum: 180
      responses:
        "201":
          description: Yard created
          content:
            application/json:
              schema:
                type: object
                required: [ok, yardId, createdAt]
                properties:
                  ok: { type: boolean, enum: [true] }
                  yardId: { type: string }
                  createdAt: { type: string, format: date-time }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: Missing name, or duplicate name within the org.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/yards/batch:
    post:
      tags: [yards]
      summary: Bulk-import up to 50 yards in one request
      operationId: createYardsBatch
      security:
        - apiKey: [yards:write]
      description: |
        Partial-success matching the other batch endpoints. Each
        entry's name is unique per org; duplicate names within the
        org (or against existing yards) surface per-entry via a
        `fieldErrors.name` validation result without failing the
        rest of the batch.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: array
                  maxItems: 50
                  items:
                    type: object
                    required: [name]
                    properties:
                      name: { type: string }
                      lat:
                        type: [number, "null"]
                        minimum: -90
                        maximum: 90
                      lng:
                        type: [number, "null"]
                        minimum: -180
                        maximum: 180
                - type: object
                  required: [entries]
                  properties:
                    entries:
                      type: array
                      maxItems: 50
      responses:
        "200":
          description: Batch processed
          content:
            application/json:
              schema:
                type: object
                required: [ok, results, summary]
                properties:
                  ok: { type: boolean, enum: [true] }
                  results:
                    type: array
                    items:
                      oneOf:
                        - type: object
                          required: [index, ok, yardId]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [true] }
                            yardId: { type: string }
                        - type: object
                          required: [index, ok, error]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [false] }
                            error: { type: string, enum: [validation_failed] }
                            fieldErrors:
                              type: object
                              additionalProperties: { type: string }
                  summary:
                    type: object
                    required: [received, succeeded, failed]
                    properties:
                      received: { type: integer }
                      succeeded: { type: integer }
                      failed: { type: integer }
        "400":
          description: Envelope rejected (not an array, empty, over 50).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/yards/{id}:
    get:
      tags: [yards]
      summary: Single yard by id (includes asset + tool counts)
      operationId: getYard
      security:
        - apiKey: [yards:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Yard detail
          content:
            application/json:
              schema:
                type: object
                required: [ok, yard]
                properties:
                  ok: { type: boolean, enum: [true] }
                  yard:
                    allOf:
                      - $ref: "#/components/schemas/Yard"
                      - type: object
                        properties:
                          assetCount: { type: integer }
                          toolCount: { type: integer }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Yard not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

    patch:
      tags: [yards]
      summary: Update a yard (name, lat, lng)
      operationId: updateYard
      security:
        - apiKey: [yards:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                lat: { type: [number, "null"], minimum: -90, maximum: 90 }
                lng: { type: [number, "null"], minimum: -180, maximum: 180 }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [ok, yardId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  yardId: { type: string }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Yard not found in this org.
        "422":
          description: |
            Empty body, blank name, or duplicate name in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

    delete:
      tags: [yards]
      summary: Delete a yard
      operationId: deleteYard
      security:
        - apiKey: [yards:write]
      description: |
        Assets and tools previously assigned to this yard are unassigned
        (SET NULL on the FK) but not deleted. No cascade.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                required: [ok, yardId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  yardId: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Yard not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/webhooks/{id}/deliveries:
    get:
      tags: [webhooks]
      summary: Recent delivery history for a subscription
      operationId: listWebhookDeliveries
      security:
        - apiKey: [events:read]
      description: |
        Newest-first, cursor-paginated (default 50, max 200). Each row
        carries the event type, attempt count, response status, and a
        truncated (500 chars) response body — enough to debug a 4xx /
        5xx receiver. Test deliveries from `/test` do NOT appear here.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          required: false
          description: |
            CSV of statuses to include (PENDING, DELIVERED, FAILED,
            ABANDONED).
          schema: { type: string, example: FAILED,ABANDONED }
      responses:
        "200":
          description: Page of delivery rows
          content:
            application/json:
              schema:
                type: object
                required: [ok, deliveries, nextCursor]
                properties:
                  ok: { type: boolean, enum: [true] }
                  deliveries:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookDelivery"
                  nextCursor: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Subscription not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/webhooks/{id}/test:
    post:
      tags: [webhooks]
      summary: Fire a synthetic test delivery at this subscription's URL
      operationId: testWebhookDelivery
      security:
        - apiKey: [events:write]
      description: |
        Sends a `flag.created` envelope with sentinel ids + an
        `X-Fleetgo-Test: 1` header to the subscription URL, signed
        with the live secret. One-shot synchronous — bypasses the
        regular delivery queue and is NOT recorded in WebhookDelivery
        history. Inactive subscriptions are refused with
        `subscription_inactive`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: |
            The request was attempted. `delivered` indicates whether
            the receiver returned 2xx; `responseStatus` and
            `responseBody` (first 500 chars) help debug failures.
          content:
            application/json:
              schema:
                type: object
                required: [ok, delivered, sentAt]
                properties:
                  ok: { type: boolean, enum: [true] }
                  delivered:
                    type: boolean
                    description: True when the receiver returned a 2xx status.
                  responseStatus: { type: [integer, "null"] }
                  responseBody: { type: [string, "null"] }
                  networkError: { type: [string, "null"] }
                  sentAt: { type: string, format: date-time }
                  signaturePreview:
                    type: string
                    description: First 16 chars of the signature for log-correlation.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Subscription not found in this org.
        "422":
          description: Subscription exists but is inactive.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/webhooks:
    get:
      tags: [webhooks]
      summary: List webhook subscriptions for this org
      operationId: listWebhooks
      security:
        - apiKey: [events:read]
      description: |
        Lists every webhook subscription registered against this
        organization. The `secret` is **not** returned — it's only
        shown once on POST. To rotate, delete and recreate.
      responses:
        "200":
          description: Page of subscriptions
          content:
            application/json:
              schema:
                type: object
                required: [ok, subscriptions]
                properties:
                  ok: { type: boolean, enum: [true] }
                  subscriptions:
                    type: array
                    items:
                      $ref: "#/components/schemas/WebhookSubscription"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [webhooks]
      summary: Create a webhook subscription
      operationId: createWebhook
      security:
        - apiKey: [events:write]
      description: |
        Creates a subscription and returns the signing secret ONCE.
        **Store it now** — there is no way to recover it later. Rotate
        by deleting and recreating the subscription.

        URL must be absolute. In production, `https://` is required;
        in development, plain `http://` is allowed.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url:
                  type: string
                  format: uri
                  example: https://your-app.example.com/webhooks/dirtfleet
                events:
                  description: |
                    CSV string or array of event types. Unknown values are
                    silently dropped. At least one valid event required.
                  oneOf:
                    - type: string
                      example: flag.created,hours.logged
                    - type: array
                      items:
                        type: string
                        enum:
                          - flag.created
                          - flag.resolved
                          - hours.logged
                          - asset.created
                          - asset.updated
                          - workorder.created
                          - workorder.completed
                          - tool.failure
                          - tool.checked_out
                          - tool.checked_in
                          - tool.low_stock
                          - tool.assignment_changed
                          - tool.serviced
                          - tool.created
                description:
                  type: [string, "null"]
                  maxLength: 200
                active:
                  type: boolean
                  default: true
      responses:
        "201":
          description: |
            Subscription created. The `secret` field is the only place
            this is ever returned — store it now or rotate.
          content:
            application/json:
              schema:
                type: object
                required: [ok, subscriptionId, secret, secretWarning, events, createdAt]
                properties:
                  ok: { type: boolean, enum: [true] }
                  subscriptionId: { type: string }
                  secret:
                    type: string
                    description: |
                      HMAC-SHA256 signing secret. Goes in the receiver's
                      env; verifies `X-Fleetgo-Signature` on incoming
                      deliveries.
                  secretWarning: { type: string }
                  events:
                    type: array
                    items: { type: string }
                  createdAt: { type: string, format: date-time }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: |
            URL missing/invalid, plaintext URL in production, or no
            valid event types.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/webhooks/{id}:
    get:
      tags: [webhooks]
      summary: Get a single subscription's current state
      operationId: getWebhook
      security:
        - apiKey: [events:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Subscription detail (secret omitted)
          content:
            application/json:
              schema:
                type: object
                required: [ok, subscription]
                properties:
                  ok: { type: boolean, enum: [true] }
                  subscription:
                    $ref: "#/components/schemas/WebhookSubscription"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Subscription not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

    patch:
      tags: [webhooks]
      summary: Toggle active or update URL / events / description
      operationId: updateWebhook
      security:
        - apiKey: [events:write]
      description: |
        Partial update. Secret cannot be rotated here — delete + recreate
        for that. Pass `active: false` to pause a subscription without
        losing the secret; `active: true` to resume.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                active: { type: boolean }
                url: { type: string, format: uri }
                events:
                  oneOf:
                    - type: string
                    - type: array
                      items: { type: string }
                description: { type: [string, "null"] }
            examples:
              pause:
                summary: Pause without delete
                value: { active: false }
              moveReceiver:
                summary: Move to a new URL, keep same secret
                value: { url: https://new-receiver.example.com/hook }
              resubscribe:
                summary: Change the event set
                value: { events: [flag.created, workorder.completed] }
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                required: [ok, subscriptionId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  subscriptionId: { type: string }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Subscription not found in this org.
        "422":
          description: |
            Empty body, invalid URL, plaintext URL in production, or
            zero valid event types.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

    delete:
      tags: [webhooks]
      summary: Delete a webhook subscription
      operationId: deleteWebhook
      security:
        - apiKey: [events:write]
      description: |
        Deletes the subscription. Recent delivery history for the
        subscription is cascade-deleted (the secret used to sign those
        deliveries is gone too, so the history would be unverifiable).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                required: [ok, subscriptionId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  subscriptionId: { type: string }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Subscription not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/flags/{id}:
    get:
      tags: [flags]
      summary: Single flag detail by id
      operationId: getFlag
      security:
        - apiKey: [flags:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Flag detail (includes resolution + serviceTaskId)
          content:
            application/json:
              schema:
                type: object
                required: [ok, flag]
                properties:
                  ok: { type: boolean, enum: [true] }
                  flag:
                    allOf:
                      - $ref: "#/components/schemas/Flag"
                      - type: object
                        properties:
                          serviceTaskId:
                            type: [string, "null"]
                            description: |
                              Set on AUTO_PM flags linked to a specific
                              ServiceTask threshold. Null for human-raised
                              flags + legacy single-threshold AUTO_PM rows.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Flag not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

    patch:
      tags: [flags]
      summary: Resolve an open flag
      operationId: resolveFlag
      security:
        - apiKey: [flags:write]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [RESOLVED]
                  description: |
                    Only RESOLVED is currently writable. Re-opening or
                    re-assigning a flag complicates the audit trail —
                    open a support ticket if you need it.
                resolutionNote:
                  type: [string, "null"]
                  maxLength: 1000
            examples:
              minimal:
                summary: Just resolve
                value: { status: RESOLVED }
              withNote:
                summary: Resolve with a note
                value:
                  status: RESOLVED
                  resolutionNote: Replaced hydraulic line, leak confirmed gone.
      responses:
        "200":
          description: Flag resolved
          content:
            application/json:
              schema:
                type: object
                required: [ok, flagId, status]
                properties:
                  ok: { type: boolean, enum: [true] }
                  flagId: { type: string }
                  status: { type: string, enum: [RESOLVED] }
        "400":
          description: Body could not be parsed as JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Insufficient scope or `key_creator_deleted`.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Either an unsupported status transition
            (`unsupported_status_transition`), the flag isn't in the
            org, or the flag is already RESOLVED.
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ValidationError"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/version:
    get:
      tags: [meta]
      summary: Build / version metadata
      operationId: version
      description: |
        Unauthenticated. Returns the deployed commit short SHA, branch,
        process start time, and uptime. Useful for "did my deploy land?"
        checks in CI and for support tickets.
      security: []
      responses:
        "200":
          description: Version metadata
          content:
            application/json:
              schema:
                type: object
                required: [ok, service, apiVersion]
                properties:
                  ok: { type: boolean, enum: [true] }
                  service: { type: string, example: dirtfleet-web }
                  apiVersion: { type: string, example: v1 }
                  commit: { type: [string, "null"] }
                  branch: { type: [string, "null"] }
                  startedAt: { type: string, format: date-time }
                  uptimeSec: { type: integer }
                  node: { type: [string, "null"] }

  /api/v1/health:
    get:
      tags: [meta]
      summary: Namespaced health probe for uptime monitors
      operationId: healthV1
      description: |
        Unauthenticated. Lighter than `/api/health` — only checks DB
        reachability and uptime. Uptime monitors should target this
        because the v1 shape is stable; the legacy path may drift.
      security: []
      responses:
        "200":
          description: Healthy
          content:
            application/json:
              schema:
                type: object
                required: [ok, database, apiVersion, uptimeSec]
                properties:
                  ok: { type: boolean, enum: [true] }
                  database:
                    type: string
                    enum: [connected, memory, error, timeout]
                  apiVersion: { type: string, example: v1 }
                  commit: { type: [string, "null"] }
                  uptimeSec: { type: integer }
        "503":
          description: |
            Degraded — DB ping returned `error` or `timeout`. Body has
            the same shape with `ok: false`.

  /api/v1/repairs:
    post:
      tags: [workOrders]
      summary: Log a completed repair
      operationId: createRepair
      security:
        - apiKey: [workorders:write]
      description: |
        Records a labor + parts + cost ledger entry. At least one of
        `assetId` or `workOrderId` is required so the repair attaches
        to something traceable; orphan logs are rejected. When the
        log carries an `assetId` and a positive `cost`, the lib runs
        a fire-and-forget cost-anomaly check on the asset's repair
        history.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                assetId:
                  type: [string, "null"]
                  description: |
                    One of `assetId` or `workOrderId` must be set.
                workOrderId:
                  type: [string, "null"]
                  description: |
                    When this is set, the WO is marked DONE on log
                    creation by the underlying lib path (in-app
                    parity).
                projectId:
                  type: [string, "null"]
                  description: |
                    Optional job-cost allocation. Labor + parts cost
                    rolls into the project's P&L.
                laborHours: { type: [number, "null"] }
                parts:
                  type: [string, "null"]
                  description: Free-form parts notes (SKU list, OEM numbers, etc.).
                cost: { type: [number, "null"] }
                notes: { type: [string, "null"] }
                meta:
                  type: [object, "null"]
                  description: Arbitrary JSON for integrations to stash typed data.
            examples:
              minimal:
                summary: Just a work-order completion
                value:
                  workOrderId: clx7wo_abc
                  laborHours: 2
                  cost: 240
              full:
                summary: Detailed repair with parts + meta
                value:
                  assetId: clx7asset_excavator
                  workOrderId: clx7wo_abc
                  laborHours: 4
                  cost: 480
                  parts: hyd line 1/2in, brass fittings ×3
                  notes: Replaced leaking line on stick boom.
                  meta:
                    laborRate: 120
                    technicianId: clx7user_jordan
      responses:
        "201":
          description: Repair log created
          content:
            application/json:
              schema:
                type: object
                required: [ok, repairId]
                properties:
                  ok: { type: boolean, enum: [true] }
                  repairId: { type: string }
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: |
            Missing both assetId + workOrderId, or referenced asset /
            work-order / project belongs to another org.
        "429":
          $ref: "#/components/responses/RateLimited"

    get:
      tags: [workOrders]
      summary: List repair logs (read-only history)
      operationId: listRepairs
      security:
        - apiKey: [workorders:read]
      description: |
        Read-only labor + parts + cost history. Filterable by assetId,
        projectId, workOrderId, or since (ISO date). Cursor-paginated,
        newest first. Reuses the `workorders:read` scope — repairs are
        the completion-side of work orders, not a separate domain.
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: assetId
          in: query
          required: false
          schema: { type: string }
        - name: projectId
          in: query
          required: false
          schema: { type: string }
        - name: workOrderId
          in: query
          required: false
          schema: { type: string }
        - name: since
          in: query
          required: false
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Page of repair logs
          content:
            application/json:
              schema:
                type: object
                required: [ok, repairs, nextCursor]
                properties:
                  ok: { type: boolean, enum: [true] }
                  repairs:
                    type: array
                    items:
                      $ref: "#/components/schemas/RepairLog"
                  nextCursor: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/changelog:
    get:
      tags: [meta]
      summary: Public changelog as structured JSON
      operationId: listChangelog
      description: |
        Unauthenticated; the same content rendered at /changelog.
        Edge-cached for an hour. Use this for status-page integrations
        and customer-success automations that want a structured
        release-note feed without parsing RSS.
      security: []
      responses:
        "200":
          description: All changelog entries
          content:
            application/json:
              schema:
                type: object
                required: [ok, entries]
                properties:
                  ok: { type: boolean, enum: [true] }
                  entries:
                    type: array
                    items:
                      type: object
                      required: [date, title, summary]
                      properties:
                        date: { type: string, format: date }
                        title: { type: string }
                        summary: { type: string }
                        tags:
                          type: array
                          items:
                            type: string
                            enum: [new, improved, fixed, security]
                        href: { type: string }

  /api/v1/hours/{id}:
    get:
      tags: [hours]
      summary: Single hours-log detail by id
      operationId: getHoursLog
      security:
        - apiKey: [hours:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Hours-log detail
          content:
            application/json:
              schema:
                type: object
                required: [ok, hoursLog]
                properties:
                  ok: { type: boolean, enum: [true] }
                  hoursLog:
                    allOf:
                      - $ref: "#/components/schemas/HoursLog"
                      - type: object
                        properties:
                          gpsAccuracyM: { type: [number, "null"] }
                          projectId: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Hours log not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/repairs/{id}:
    get:
      tags: [workOrders]
      summary: Single repair-log detail by id
      operationId: getRepair
      security:
        - apiKey: [workorders:read]
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Repair detail (includes the meta JSON field)
          content:
            application/json:
              schema:
                type: object
                required: [ok, repair]
                properties:
                  ok: { type: boolean, enum: [true] }
                  repair:
                    allOf:
                      - $ref: "#/components/schemas/RepairLog"
                      - type: object
                        properties:
                          meta:
                            type: [object, "null"]
                            description: |
                              Arbitrary JSON blob the lib may stash on
                              the repair (typed cost-breakdowns, OEM
                              part numbers, etc). Omitted from the list
                              endpoint to keep the listing snappy.
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          description: Repair log not found in this org.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/hours/batch:
    post:
      tags: [hours]
      summary: Bulk-import up to 100 hours logs in one request
      operationId: createHoursLogsBatch
      security:
        - apiKey: [hours:write]
      description: |
        Partial-success semantics: each entry is validated + written
        independently, and the response carries a parallel `results`
        array. A single bad entry does NOT fail the whole batch; the
        envelope returns 200 with per-entry `ok: false` results.

        Envelope-level errors (not an array, empty, over-limit) return
        400. Each entry can carry its own `clientMutationId` for
        per-row idempotency — array-level Idempotency-Key isn't bound
        here, since per-entry keys handle dedupe.

        Cap: 100 entries per request. Bigger batches: call multiple
        times.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: array
                  items:
                    $ref: "#/components/schemas/HoursLogInput"
                  maxItems: 100
                - type: object
                  required: [entries]
                  properties:
                    entries:
                      type: array
                      items:
                        $ref: "#/components/schemas/HoursLogInput"
                      maxItems: 100
            examples:
              twoEntries:
                summary: Direct array form
                value:
                  - { assetId: clx7asset_a, hoursReading: 100 }
                  - { assetId: clx7asset_b, hoursReading: 200 }
              namedEntries:
                summary: Wrapped form for HTTP clients that dislike raw arrays
                value:
                  entries:
                    - { assetId: clx7asset_a, hoursReading: 100, clientMutationId: c1 }
                    - { assetId: clx7asset_b, hoursReading: 200, clientMutationId: c2 }
      responses:
        "200":
          description: |
            Batch processed. `results[]` is parallel to the input order;
            `summary` reports rolled-up counts.
          content:
            application/json:
              schema:
                type: object
                required: [ok, results, summary]
                properties:
                  ok: { type: boolean, enum: [true] }
                  results:
                    type: array
                    items:
                      oneOf:
                        - type: object
                          required: [index, ok, logId, autoFlagId]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [true] }
                            logId: { type: string }
                            autoFlagId: { type: [string, "null"] }
                        - type: object
                          required: [index, ok, error]
                          properties:
                            index: { type: integer }
                            ok: { type: boolean, enum: [false] }
                            error:
                              type: string
                              enum: [validation_failed]
                            formError: { type: [string, "null"] }
                            fieldErrors:
                              type: object
                              additionalProperties: { type: string }
                  summary:
                    type: object
                    required: [received, succeeded, failed]
                    properties:
                      received: { type: integer }
                      succeeded: { type: integer }
                      failed: { type: integer }
        "400":
          description: |
            Envelope rejected: `invalid_envelope` (not an array),
            `empty_batch`, or `batch_too_large` (over 100 entries).
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/me/usage:
    get:
      tags: [meta]
      summary: Current API key's rate-limit window state
      operationId: usage
      security:
        - apiKey: []
      description: |
        Returns the same numbers as the `X-RateLimit-*` response
        headers, in a parseable body. This call itself counts against
        the budget; the returned `remaining` reflects the state AFTER
        this call.
      responses:
        "200":
          description: Current budget snapshot
          content:
            application/json:
              schema:
                type: object
                required: [ok, keyId, rateLimit]
                properties:
                  ok: { type: boolean, enum: [true] }
                  keyId: { type: string }
                  rateLimit:
                    type: object
                    required: [limit, remaining, resetEpochSec, resetIn, windowSec]
                    properties:
                      limit: { type: integer, example: 60 }
                      remaining: { type: integer }
                      resetEpochSec:
                        type: integer
                        description: Unix epoch seconds when the window resets.
                      resetIn:
                        type: integer
                        description: Seconds until reset (negative clamped to 0).
                      windowSec: { type: integer, example: 60 }
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/work-orders:
    get:
      tags: [workOrders]
      summary: List work orders (cursor-paginated, defaults to active queue)
      operationId: listWorkOrders
      security:
        - apiKey: [workorders:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: status
          in: query
          required: false
          description: |
            CSV. Defaults to the active queue (OPEN,ASSIGNED,IN_PROGRESS,ON_HOLD).
            Pass explicit values to widen — e.g. `status=DONE` for the
            recently-completed list.
          schema:
            type: string
            example: OPEN,ASSIGNED
        - name: priority
          in: query
          required: false
          description: CSV (LOW, NORMAL, HIGH, URGENT). Unknown values ignored.
          schema:
            type: string
            example: HIGH,URGENT
        - name: assetId
          in: query
          required: false
          schema: { type: string }
        - name: assignedToId
          in: query
          required: false
          description: Filter to one mechanic's queue.
          schema: { type: string }
      responses:
        "200":
          description: Page of work orders, ordered by priority desc / due asc / created desc
          content:
            application/json:
              schema:
                type: object
                required: [ok, workOrders, nextCursor]
                properties:
                  ok: { type: boolean, enum: [true] }
                  workOrders:
                    type: array
                    items:
                      $ref: "#/components/schemas/WorkOrder"
                  nextCursor: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [workOrders]
      summary: Create a new work order
      operationId: createWorkOrder
      security:
        - apiKey: [workorders:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [title]
              properties:
                title:
                  type: string
                  maxLength: 200
                description:
                  type: [string, "null"]
                  maxLength: 8000
                assetId: { type: [string, "null"] }
                projectId: { type: [string, "null"] }
                flagId:
                  type: [string, "null"]
                  description: |
                    Link to the field-flag that spawned this WO. When the WO
                    later completes with a RepairLog, the flag auto-resolves.
                priority:
                  type: string
                  enum: [LOW, NORMAL, HIGH, URGENT]
                  default: NORMAL
                status:
                  type: string
                  enum: [OPEN, ASSIGNED, IN_PROGRESS, ON_HOLD]
                  default: OPEN
                assignedToId: { type: [string, "null"] }
                dueAt: { type: [string, "null"], format: date-time }
                estimateLaborHours: { type: [number, "null"] }
                estimateCost: { type: [number, "null"] }
            examples:
              minimal:
                summary: Bare minimum — just a title
                value:
                  title: Replace failed hydraulic line
              flagSpawned:
                summary: Promote a flag into a work order
                value:
                  title: Hydraulic leak follow-up
                  flagId: clx7flag_abc
                  assetId: clx7asset_excavator
                  priority: URGENT
                  assignedToId: clx7mech_jordan
                  status: ASSIGNED
                  estimateLaborHours: 4
                  estimateCost: 480
      responses:
        "201":
          description: Work order created
          content:
            application/json:
              schema:
                type: object
                required: [ok, workOrderId, number]
                properties:
                  ok: { type: boolean, enum: [true] }
                  workOrderId: { type: string }
                  number:
                    type: integer
                    description: Per-org sequential reference, allocated server-side.
        "400":
          description: Body could not be parsed as JSON
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          description: |
            Title missing, referenced asset / project / flag belongs to
            another org, or another validation rejection.
        "429":
          $ref: "#/components/responses/RateLimited"

  /api/v1/hours:
    get:
      tags: [hours]
      summary: List hours logs (cursor-paginated, newest first)
      operationId: listHoursLogs
      security:
        - apiKey: [hours:read]
      parameters:
        - $ref: "#/components/parameters/Limit"
        - $ref: "#/components/parameters/Cursor"
        - name: assetId
          in: query
          required: false
          description: Restrict to logs against a single asset.
          schema: { type: string }
        - name: since
          in: query
          required: false
          description: |
            ISO timestamp. Returns logs with `loggedAt >= since`. Bad values
            are silently ignored rather than 400'd.
          schema: { type: string, format: date-time, example: "2026-05-01T00:00:00Z" }
      responses:
        "200":
          description: Page of hours logs
          content:
            application/json:
              schema:
                type: object
                required: [ok, hoursLogs, nextCursor]
                properties:
                  ok: { type: boolean, enum: [true] }
                  hoursLogs:
                    type: array
                    items:
                      $ref: "#/components/schemas/HoursLog"
                  nextCursor: { type: [string, "null"] }
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "429":
          $ref: "#/components/responses/RateLimited"

    post:
      tags: [hours]
      summary: Log an hours / odometer / state reading for an asset
      operationId: createHoursLog
      security:
        - apiKey: [hours:write]
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Opaque string up to 128 chars. A retry with the same key returns
            the original log id without creating a duplicate. Overrides
            `clientMutationId` in the body if both are present.
          schema:
            type: string
            example: c8e3a7d2-9c11-4b3f-8d92-1d40c93b3f12
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/HoursLogInput"
            examples:
              numericMeter:
                summary: HOURS or ODOMETER asset — supply hoursReading
                value:
                  assetId: clx7asset_excavator
                  hoursReading: 1342.7
                  note: End-of-shift reading
              stateOnly:
                summary: NONE-meter asset (e.g. trailer) — supply state
                value:
                  assetId: clx7asset_trailer
                  state: IN_USE
              withFuel:
                summary: Numeric reading + fuel
                value:
                  assetId: clx7asset_pickup
                  hoursReading: 47812
                  fuelVolume: 22.1
                  fuelUnit: GALLON
                  fuelCostTotal: 92.34
      responses:
        "201":
          description: Log created
          content:
            application/json:
              schema:
                type: object
                required: [ok, logId, autoFlagId]
                properties:
                  ok:
                    type: boolean
                    enum: [true]
                  logId: { type: string }
                  autoFlagId:
                    type: [string, "null"]
                    description: |
                      Set when this reading crossed the asset's PM threshold
                      and an AUTO_PM Flag was spawned. Null for routine logs.
        "400":
          description: Body could not be parsed as JSON
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          description: |
            Insufficient scope, or the API key's creator user has been deleted
            (`key_creator_deleted`).
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "422":
          description: |
            Schema validation failed. `fieldErrors` maps the offending input
            field to a human-readable message. Examples:
            `assetId: "Pick an asset"`,
            `hoursReading: "Enter the meter reading"`.
          content:
            application/json:
              schema:
                type: object
                required: [ok, error, fieldErrors]
                properties:
                  ok: { type: boolean, enum: [false] }
                  error: { type: string, enum: [validation_failed] }
                  formError: { type: [string, "null"] }
                  fieldErrors:
                    type: object
                    additionalProperties: { type: string }
        "429":
          $ref: "#/components/responses/RateLimited"

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      bearerFormat: dfk_<43 base64url chars>
      description: |
        Issued by org admins at /settings → API keys. The plaintext is
        shown ONCE on creation; the SHA-256 hash is stored server-side.
        Lost = revoke + reissue. Each key carries a CSV of named scopes
        (assets:read, flags:read, etc.) — requests outside scope get 403.

  parameters:
    Limit:
      name: limit
      in: query
      required: false
      description: Page size, clamped to [1, 500]. Default 100.
      schema:
        type: integer
        minimum: 1
        maximum: 500
        default: 100
    Cursor:
      name: cursor
      in: query
      required: false
      description: Cursor returned by the previous page's `nextCursor`.
      schema:
        type: string

  responses:
    Unauthorized:
      description: |
        Bearer header missing, malformed, unknown, or referencing a
        revoked/expired key.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            missingBearer:
              value: { ok: false, error: missing_bearer }
            unknownToken:
              value: { ok: false, error: unknown_token }
    Forbidden:
      description: Authenticated, but the API key lacks the required scope.
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/Error"
          examples:
            missingScope:
              value: { ok: false, error: "missing_scope:flags:read" }
    RateLimited:
      description: |
        Per-key limit exceeded. Every response (success or 429) includes:
        `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`
        (epoch seconds when the window resets). On 429, `Retry-After`
        gives the back-off in seconds. Default cap is 60 req/min per key;
        higher caps negotiated commercially on Professional / Enterprise.
      headers:
        Retry-After:
          schema: { type: integer }
          description: Seconds until the window resets.
        X-RateLimit-Limit:
          schema: { type: integer }
        X-RateLimit-Remaining:
          schema: { type: integer }
        X-RateLimit-Reset:
          schema: { type: integer, description: Unix epoch seconds }
      content:
        application/json:
          schema:
            type: object
            required: [ok, error]
            properties:
              ok: { type: boolean, enum: [false] }
              error: { type: string, enum: [rate_limited] }
              retryAfterSec: { type: integer }

  schemas:
    Error:
      type: object
      required: [ok, error]
      properties:
        ok:
          type: boolean
          enum: [false]
        error:
          type: string
          description: |
            Stable error code. Examples:
            `missing_bearer`, `malformed_authorization`, `malformed_token`,
            `unknown_token`, `revoked`, `expired`, `missing_scope:<scope>`.

    Asset:
      type: object
      required: [id, nickname, meterType, meterUnit, lifecycleStatus, createdAt, updatedAt]
      properties:
        id: { type: string, example: clx7abc123 }
        nickname: { type: string, example: Excavator 47 }
        customerAssetNumber:
          type: [string, "null"]
          example: EX-047
        assetClass:
          type: [string, "null"]
          example: off-road
        vin: { type: [string, "null"], example: null }
        serial:
          type: [string, "null"]
          example: CAT320-12345
        meterType:
          type: string
          enum: [HOURS, MILES, ODOMETER_KM]
          example: HOURS
        meterUnit: { type: string, example: hrs }
        lifecycleStatus:
          type: string
          enum: [IN_SERVICE, OUT_OF_SERVICE, ARCHIVED, SOLD]
        yardId: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Flag:
      type: object
      required: [id, assetId, severity, status, createdAt, updatedAt]
      properties:
        id: { type: string }
        assetId: { type: string }
        severity:
          type: string
          enum: [YELLOW, RED, AUTO_PM]
        status:
          type: string
          enum: [OPEN, IN_PROGRESS, RESOLVED]
        note: { type: [string, "null"] }
        raisedById: { type: [string, "null"] }
        resolvedById: { type: [string, "null"] }
        resolvedAt: { type: [string, "null"], format: date-time }
        resolutionNote: { type: [string, "null"] }
        lat: { type: [number, "null"], format: float }
        lng: { type: [number, "null"], format: float }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    AssetDetail:
      description: |
        Single-asset shape — superset of the list `Asset` schema. Adds
        meter PM state, plate / renewal fields, financial summary, and
        the parent attachment + primary operator pointers. Excludes the
        cover-photo blob and the per-asset event log (those are their
        own future endpoints).
      type: object
      required: [id, nickname, meterType, lifecycleStatus, createdAt, updatedAt]
      properties:
        id: { type: string }
        nickname: { type: string }
        customerAssetNumber: { type: [string, "null"] }
        assetClass: { type: [string, "null"] }
        vin: { type: [string, "null"] }
        serial: { type: [string, "null"] }
        meterType:
          type: string
          enum: [HOURS, MILES, ODOMETER_KM, NONE]
        meterUnit: { type: [string, "null"] }
        lifecycleStatus:
          type: string
          enum: [IN_SERVICE, OUT_OF_SERVICE, ARCHIVED, SOLD]
        yardId: { type: [string, "null"] }
        primaryOperatorId: { type: [string, "null"] }
        parentAssetId:
          type: [string, "null"]
          description: When set, this asset is an attachment of another (excluded from billing).
        serviceIntervalHrs:
          type: [integer, "null"]
          description: PM threshold; when current reading crosses (baseline + interval) we auto-flag yellow.
        serviceBaselineHrs:
          type: [number, "null"]
          description: Reading at the most recent service log.
        licensePlate: { type: [string, "null"] }
        licenseState: { type: [string, "null"] }
        registrationExpiresAt: { type: [string, "null"], format: date-time }
        insuranceExpiresAt: { type: [string, "null"], format: date-time }
        purchaseCost: { type: [number, "null"] }
        purchaseDate: { type: [string, "null"], format: date-time }
        depreciationMethod:
          type: string
          enum: [NONE, STRAIGHT_LINE, DECLINING_BALANCE]
        usefulLifeYears: { type: [integer, "null"] }
        currentValue:
          type: [number, "null"]
          description: |
            Optional override; if null, computed from purchaseCost +
            depreciation method + age.
        soldAt: { type: [string, "null"], format: date-time }
        soldPrice: { type: [number, "null"] }
        soldTo: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Tool:
      type: object
      required: [id, name, status, isConsumable, createdAt, updatedAt]
      properties:
        id: { type: string }
        name: { type: string }
        category:
          type: [string, "null"]
          description: Free-form ("wrench", "power tool", "welder", "consumable").
        makeModel: { type: [string, "null"] }
        serial: { type: [string, "null"] }
        status:
          type: string
          enum: [GOOD, NEEDS_ATTENTION, BROKEN, MISSING, RETIRED]
        isConsumable:
          type: boolean
          description: When true, the tool uses stock-level UI instead of check-in/out.
        stockLevel: { type: [integer, "null"] }
        minStockLevel:
          type: [integer, "null"]
          description: |
            Reorder threshold for consumables. The `?lowStock=true` filter
            returns tools where `stockLevel <= COALESCE(minStockLevel, 5)`.
            Null = use the org-wide fallback (5). Set to 0 to opt the tool
            out of low-stock alerts entirely.
        parentKitId:
          type: [string, "null"]
          description: Parent kit/set when this tool is a child item.
        assignedUserId:
          type: [string, "null"]
          description: Long-arc responsibility assignment (storage location / owner).
        assignedVehicleId: { type: [string, "null"] }
        purchaseDate: { type: [string, "null"], format: date-time }
        purchaseCost: { type: [number, "null"] }
        warrantyEndDate: { type: [string, "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    Project:
      type: object
      required: [id, name, status, createdAt, updatedAt]
      properties:
        id: { type: string }
        name: { type: string }
        code:
          type: [string, "null"]
          description: Short reference / job number ("J-2026-014"). Unique per org.
        customerName: { type: [string, "null"] }
        status:
          type: string
          enum: [ACTIVE, ON_HOLD, COMPLETED, ARCHIVED]
        startDate: { type: [string, "null"], format: date-time }
        endDate: { type: [string, "null"], format: date-time }
        hourlyRate:
          type: [number, "null"]
          description: Optional billable hourly rate. When set, drives P&L revenue = hours × rate.
        notes: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    HoursLog:
      type: object
      required: [id, assetId, loggedAt, createdAt]
      properties:
        id: { type: string }
        assetId: { type: string }
        loggedById:
          type: [string, "null"]
          description: User who logged this row. Null when sourced from a hardware device.
        loggedByDeviceId:
          type: [string, "null"]
          description: Device that reported this row. Null for human-logged entries.
        hoursReading:
          type: [number, "null"]
          description: Meter reading in the asset's `meterUnit`. Null for NONE-meter assets.
        delta:
          type: [number, "null"]
          description: Difference from the previous reading on the same asset (server-computed).
        state:
          type: [string, "null"]
          enum: [IN_USE, IDLE, OUT_OF_SERVICE, null]
        fuelVolume: { type: [number, "null"] }
        fuelUnit:
          type: [string, "null"]
          enum: [GALLON, LITER, null]
        fuelCostTotal: { type: [number, "null"] }
        note: { type: [string, "null"] }
        lat: { type: [number, "null"] }
        lng: { type: [number, "null"] }
        loggedAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }

    WorkOrder:
      type: object
      required: [id, number, title, status, priority, createdAt, updatedAt]
      properties:
        id: { type: string }
        number:
          type: integer
          description: Per-org sequential reference (greppable in chat — "WO-1847").
        assetId: { type: [string, "null"] }
        projectId: { type: [string, "null"] }
        flagId:
          type: [string, "null"]
          description: Set when the WO was spawned from a Flag.
        title: { type: string }
        description: { type: [string, "null"] }
        status:
          type: string
          enum: [OPEN, ASSIGNED, IN_PROGRESS, ON_HOLD, CANCELED, DONE]
        priority:
          type: string
          enum: [LOW, NORMAL, HIGH, URGENT]
        dueAt: { type: [string, "null"], format: date-time }
        estimateLaborHours: { type: [number, "null"] }
        estimateCost: { type: [number, "null"] }
        createdById: { type: [string, "null"] }
        assignedToId: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    ValidationError:
      type: object
      required: [ok, error, fieldErrors]
      properties:
        ok: { type: boolean, enum: [false] }
        error: { type: string, enum: [validation_failed] }
        formError: { type: [string, "null"] }
        fieldErrors:
          type: object
          additionalProperties: { type: string }

    HoursLogInput:
      type: object
      required: [assetId]
      description: |
        One of `hoursReading` (for HOURS / ODOMETER assets) or `state` (for
        NONE-meter assets) is required — the server resolves which based on
        the asset's `meterType`. Sending the wrong one returns 422.
      properties:
        assetId: { type: string }
        hoursReading:
          type: number
          minimum: 0
          maximum: 9999999
          description: Meter reading in the asset's `meterUnit`. Required for HOURS / ODOMETER assets.
        state:
          type: string
          enum: [IN_USE, IDLE, OUT_OF_SERVICE]
          description: Required for NONE-meter assets (trailers, attachments, etc.).
        fuelVolume: { type: number, minimum: 0, maximum: 9999 }
        fuelUnit:
          type: string
          enum: [GALLON, LITER]
        fuelCostTotal: { type: number, minimum: 0, maximum: 9999999 }
        note:
          type: [string, "null"]
          maxLength: 500
        projectId:
          type: [string, "null"]
          description: Optional job-cost allocation. Silently ignored if the project belongs to a different org.
        clientMutationId:
          type: [string, "null"]
          description: |
            Body-level idempotency key. Header `Idempotency-Key` takes
            precedence if both are present.
        geo:
          type: [object, "null"]
          properties:
            lat: { type: number, minimum: -90, maximum: 90 }
            lng: { type: number, minimum: -180, maximum: 180 }
            accuracyM: { type: number, minimum: 0, maximum: 100000 }

    RepairLog:
      type: object
      required: [id, createdAt]
      properties:
        id: { type: string }
        assetId:
          type: [string, "null"]
          description: Null when the original asset was deleted (SET NULL on FK).
        projectId: { type: [string, "null"] }
        workOrderId:
          type: [string, "null"]
          description: Set when this repair closed out a WorkOrder.
        laborHours: { type: [number, "null"] }
        parts: { type: [string, "null"] }
        cost: { type: [number, "null"] }
        notes: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }

    Yard:
      type: object
      required: [id, name, createdAt, updatedAt]
      properties:
        id: { type: string }
        name:
          type: string
          description: Unique within the org.
        lat: { type: [number, "null"] }
        lng: { type: [number, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    WebhookDelivery:
      type: object
      required: [id, eventType, status, attempts, createdAt]
      properties:
        id: { type: string }
        eventType: { type: string, example: flag.created }
        status:
          type: string
          enum: [PENDING, DELIVERED, FAILED, ABANDONED]
        responseStatus:
          type: [integer, "null"]
          description: HTTP status returned by the receiver.
        responseBody:
          type: [string, "null"]
          description: First 500 chars of the receiver's response body.
        attempts: { type: integer }
        nextAttemptAt: { type: [string, "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        deliveredAt: { type: [string, "null"], format: date-time }

    WebhookSubscription:
      type: object
      required: [id, url, events, active, createdAt, updatedAt]
      description: |
        Webhook subscription metadata. The `secret` field is intentionally
        omitted from listings — it's only ever returned on the POST
        response. Rotate by delete + recreate.
      properties:
        id: { type: string }
        url: { type: string, format: uri }
        events:
          type: array
          items: { type: string }
          description: Event types this subscription receives.
        active: { type: boolean }
        description: { type: [string, "null"] }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    WebhookEnvelope:
      description: |
        The JSON body POSTed to a webhook subscription URL. Verify the
        `X-Fleetgo-Signature` header against the raw body before parsing.
      type: object
      required: [id, event, organizationId, sentAt, data]
      properties:
        id:
          type: string
          description: Delivery id — use as the idempotency key on retries.
        event:
          type: string
          enum:
            - flag.created
            - flag.resolved
            - hours.logged
            - asset.created
            - asset.updated
            - workorder.created
            - workorder.completed
            - tool.failure
        organizationId: { type: string }
        sentAt: { type: string, format: date-time }
        data:
          type: object
          description: |
            Event-specific payload. Shape depends on `event`. See the
            in-app webhook subscription page for the latest examples.
          additionalProperties: true
