openapi: 3.1.0

# ==============================================================================
# The intake API — a customer's own SYSTEM, not a person
# ==============================================================================
#
# The prose version of this file is docs/api/integration.md, and it is the one to
# read first: it explains WHY the decisions here are what they are. This file is
# the machine-readable half, for generating a client.
#
# It is kept honest by tests/Feature/Api/V1/Integration/SpecTest.php, which
# parses this document and asserts that every path in it exists in the
# application's route table and that every route under /api/v1/integration
# appears here. Adding an endpoint without adding it to this file fails the
# suite — which is the point, because a spec that has quietly fallen behind the
# code is worse than no spec at all.
#
# THREE SURFACES, AND THIS IS THE THIRD. `?action2=` is the frozen shim serving
# ~75 shipped iPhones (docs/legacy-api/), /api/v1 is the field app's own API
# (docs/api/v1.md), and this is what a customer's software calls. Different
# audiences, different credentials, versioned independently.

info:
  title: WorkOrders Intake API
  version: '1.0'
  summary: Raise and follow work orders from a customer's own system.
  description: |
    The API a **customer's own system** calls — a building management platform, a
    tenant portal, or an in-house maintenance schedule raising a work order over
    HTTP instead of emailing one in, then reading back what came of it.

    ## The credential is per-COMPANY

    There is no user here. Nobody is signed in, and the key's own row is the only
    thing that decides whose data a request sees. An administrator mints one at
    `/settings/api-keys`; the plaintext is shown once and is never recoverable,
    because a key that can be re-read from the database is a key that a database
    dump hands over.

    ## A job raised here is an ordinary job

    From the moment it lands nothing downstream can tell it from an emailed one:
    the same AI extraction pass, the same notifications, the same board, the same
    customer portal, the same reference to read down the phone.

    ## What this surface deliberately cannot do

    There is no way to change a job's status, and there will not be one. Every
    transition in this application carries an ACTOR — a named person who
    assigned, attended or completed — and a company key has nobody behind it. An
    endpoint that completed a job on behalf of nobody would put a lie in a
    history that people rely on. Use `POST /work-orders/{reference}/notes` to add
    information without moving the status.

    ## Timestamps

    ISO-8601 UTC, always with a trailing `Z`. `GET /ping` returns the server's
    own clock so an integrator whose box is an hour out discovers it there rather
    than in the shape of jobs filed at the wrong time.

    The one exception is the `created_from` / `created_to` filters, which are
    DAYS resolved in `Pacific/Auckland` — the office asking for "everything on
    Friday" means their Friday.

    ## Rate limits

    120 requests a minute, keyed on the TOKEN rather than the IP: a customer's
    own server means several tenants routinely share one address (a corporate
    NAT, a facilities platform hosting a dozen property managers), and a per-IP
    bucket there would let one busy integration throttle an unrelated company's.

    Every authenticated response carries `X-RateLimit-Limit` and
    `X-RateLimit-Remaining`. A rejected key is refused BEFORE the limiter, so a
    401 consumes no budget.
  contact:
    name: WorkOrders
    url: https://workorders.nz/developers
  license:
    name: Proprietary

servers:
  - url: https://workorders.nz/api/v1/integration
    description: Production
  # Tenant subdomains answer here too rather than redirecting to the apex: these
  # routes are outside EnsureApexHost, because a 301 in front of a POST drops the
  # body and an integrator will have been given whatever host the office read out
  # to them.

security:
  - bearerAuth: []

tags:
  - name: Diagnostics
    description: Proving a key before writing any code.
  - name: Work orders
    description: Raising jobs and reading back what came of them.
  - name: Attachments
    description: Files too large to send inline with the job.

paths:

  # ---------------------------------------------------------------- /ping ----
  /ping:
    get:
      tags: [Diagnostics]
      operationId: ping
      summary: Does this key work, and as whom?
      description: |
        The first thing to run, in one curl, before writing a line of code. It
        answers the three questions that otherwise become a support email: is the
        key live, which company does it act for — the thing to check before
        posting a job into somebody else's account after a copy-and-paste — and
        what is it allowed to do.

        Deliberately requires **no ability**. A key that cannot say hello cannot
        be diagnosed, and a read-only key must be able to prove itself without
        thereby being able to create work.

        It also reads nothing tenant-owned on purpose: a key minted five seconds
        ago has nothing to list yet, and "your key works" must not depend on the
        account having data in it.
      responses:
        '200':
          description: The key is live.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Ping' }
              example:
                data:
                  company:
                    id: 1
                    name: Demo Plumbing & Gas
                  token:
                    name: Acme FM platform
                    abilities: ['*']
                    expires_at: null
                  server_time: '2026-08-06T22:15:55Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # -------------------------------------------------------- /work-orders ----
  /work-orders:

    get:
      tags: [Work orders]
      operationId: listWorkOrders
      summary: List work orders
      description: |
        Newest first (`created_at desc, id desc`), in the standard Laravel
        pagination envelope. Filters mirror the office board rather than the
        field app's sync shape — this caller is a facilities platform reading its
        own jobs, not a phone maintaining a local cache.

        Rows here carry no `events` key. A page of 25 jobs is not the place for
        25 histories; nothing else about the shape is conditional, so one model
        covers list, show and create.

        Follow `links.next` rather than incrementing `page` — it carries the
        filters forward.
      security:
        - bearerAuth: [work-orders:read]
      parameters:
        - name: status
          in: query
          description: |
            Comma-separated. **Deleted jobs are hidden unless `deleted` is named
            explicitly** — duplicates and spam leaving the queue should not
            reappear in a customer's dashboard as if they were live work. An
            unknown word is a 422 naming that word rather than the whole string.
          schema:
            type: string
            examples: ['new,assigned']
        - name: assignee
          in: query
          description: |
            A user id, or `none` for unclaimed. Deliberately NOT `me` — a company
            key has no user for that to mean anything about.
          schema:
            type: string
            pattern: '^(none|[0-9]+)$'
            examples: ['none']
        - name: customer
          in: query
          description: |
            A customer id, or the email address a synthesized customer was built
            from. An integrator knows the address it reported long before it
            knows the row id we gave it.
          schema:
            type: string
            examples: ['dana@acmefm.example']
        - name: created_from
          in: query
          description: A day, inclusive, resolved in `Pacific/Auckland`.
          schema: { type: string, format: date, examples: ['2026-08-01'] }
        - name: created_to
          in: query
          description: A day, inclusive, resolved in `Pacific/Auckland`.
          schema: { type: string, format: date, examples: ['2026-08-31'] }
        - name: updated_since
          in: query
          description: |
            An instant. Returns rows with `updated_at >=` it — `>=` and not `>`,
            because `updated_at` has one-second resolution and `>` would skip a
            second row saved inside the same second as the cursor and never
            return it again. Polling must fail towards repetition.
          schema: { type: string, format: date-time, examples: ['2026-08-06T22:00:00Z'] }
        - name: q
          in: query
          description: Free text across reference, subject, summary and requester name/email.
          schema: { type: string, maxLength: 255 }
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: A page of work orders.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginationEnvelope'
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WorkOrder' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

    post:
      tags: [Work orders]
      operationId: createWorkOrder
      summary: Raise a work order
      description: |
        The endpoint the whole surface exists for: the 3am burst pipe, raised by
        software.

        There is no `company_id` on the body and there must not be — the company
        is settled at the door from the key. There is no `status` and no
        `assigned_user_id` either: those are decisions the office makes, with a
        named person behind them.

        **`summary`, `site`, and an `urgency` you did not send are null in this
        response and are filled in seconds later** by the extraction pass. Re-read
        the job rather than treating their absence as a failure.

        Send an `Idempotency-Key`. A system that posts a work order and never
        hears back does not know whether we made the job, so retrying is the only
        responsible thing it can do — and without a key that is also how one burst
        pipe becomes three jobs on the board.
      security:
        - bearerAuth: [work-orders:write]
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/CreateWorkOrder' }
            example:
              subject: No hot water — Level 3 kitchen
              body: >-
                Reported by the duty manager. The cylinder at 12 Bond Street is
                cold and there is water on the floor.
              requester_name: Dana Whitfield
              requester_email: dana@acmefm.example
              site_address: 12 Bond Street, Wellington
              urgency: high
              external_reference: FM-99213
      responses:
        '201':
          description: |
            The job was created — or, with `Idempotent-Replay: true`, an earlier
            identical request's answer is being replayed verbatim. Replayed and
            not regenerated: a job that has since been assigned and completed must
            not come back to a retry looking like it was born that way.
          headers:
            Location:
              description: Where the job lives on this surface, keyed by reference.
              schema:
                type: string
                format: uri
                examples: ['https://workorders.nz/api/v1/integration/work-orders/WO-P6J9CE']
            Idempotent-Replay:
              description: Present and `true` only on a replay.
              schema: { type: string, examples: ['true'] }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/WorkOrder' }
              example:
                data:
                  id: 1
                  reference: WO-P6J9CE
                  status: new
                  status_label: New
                  source: api
                  urgency: high
                  subject: No hot water — Level 3 kitchen
                  summary: null
                  body:
                    text: >-
                      Reported by the duty manager. The cylinder at 12 Bond
                      Street is cold and there is water on the floor.
                    html: null
                    markdown: null
                  from:
                    name: Dana Whitfield
                    email: dana@acmefm.example
                  customer:
                    id: 1
                    name: Dana Whitfield
                    email: dana@acmefm.example
                    phone: null
                    is_synthesized: true
                  site: null
                  site_address: 12 Bond Street, Wellington
                  assignee: null
                  hours_attending: null
                  minutes_on_site: null
                  suggested_minutes_on_site: null
                  completed_note: null
                  health_note: null
                  deleted_reason: null
                  accepted_at: null
                  completed_at: null
                  created_at: '2026-08-06T22:15:55Z'
                  updated_at: '2026-08-06T22:15:55Z'
                  attachments: []
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '409': { $ref: '#/components/responses/IdempotencyConflict' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # -------------------------------------------- /work-orders/{reference} ----
  /work-orders/{reference}:
    parameters:
      - $ref: '#/components/parameters/Reference'
    get:
      tags: [Work orders]
      operationId: showWorkOrder
      summary: One work order, with its timeline
      description: |
        The full record, including `events` — the one key absent from list rows.
      security:
        - bearerAuth: [work-orders:read]
      responses:
        '200':
          description: The job.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    allOf:
                      - $ref: '#/components/schemas/WorkOrder'
                      - type: object
                        properties:
                          events:
                            type: array
                            items: { $ref: '#/components/schemas/WorkOrderEvent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # ------------------------------------- /work-orders/{reference}/events ----
  /work-orders/{reference}/events:
    parameters:
      - $ref: '#/components/parameters/Reference'
    get:
      tags: [Work orders]
      operationId: listWorkOrderEvents
      summary: The timeline alone
      description: |
        **Oldest first** — the order a story is told in, and the opposite of the
        job list, which is newest-first because that is what a board of open jobs
        wants.

        This is the endpoint to poll to mirror progress: it says who did what and
        when, which the status column alone cannot.
      security:
        - bearerAuth: [work-orders:read]
      parameters:
        - $ref: '#/components/parameters/PerPage'
        - $ref: '#/components/parameters/Page'
      responses:
        '200':
          description: A page of events, oldest first.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/PaginationEnvelope'
                  - type: object
                    required: [data]
                    properties:
                      data:
                        type: array
                        items: { $ref: '#/components/schemas/WorkOrderEvent' }
              example:
                data:
                  - id: 1
                    type: created
                    label: Created
                    note: Created via the API.
                    data:
                      api_token_id: 1
                      api_token_name: Acme FM platform
                      external_reference: FM-99213
                    actor: null
                    actor_name: System
                    created_at: '2026-08-06T22:15:55Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # -------------------------------------- /work-orders/{reference}/notes ----
  /work-orders/{reference}/notes:
    parameters:
      - $ref: '#/components/parameters/Reference'
    post:
      tags: [Work orders]
      operationId: addWorkOrderNote
      summary: Append to a job's history
      description: |
        Adds information without moving the status — "the tenant says the leak has
        got worse", "the building manager has isolated the riser". The note
        appears on the job in the office and on the technician's phone.

        Its event type is `api_note` rather than `ongoing_note`, so the timeline
        never implies a technician said something a machine did.

        **Not idempotent.** `Idempotency-Key` is honoured on create only; a
        retried note is a second note.
      security:
        - bearerAuth: [work-orders:write]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [note]
              properties:
                note:
                  type: string
                  maxLength: 20000
              example:
                note: >-
                  Tenant reports the leak has got worse. Building manager has
                  isolated the riser.
      responses:
        '201':
          description: The event that was recorded.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/WorkOrderEvent' }
              example:
                data:
                  id: 2
                  type: api_note
                  label: Note added via the API
                  note: >-
                    Tenant reports the leak has got worse. Building manager has
                    isolated the riser.
                  data:
                    api_token_id: 1
                    api_token_name: Acme FM platform
                  actor: null
                  actor_name: System
                  created_at: '2026-08-06T22:15:56Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

  # -------------------------------- /work-orders/{reference}/attachments ----
  /work-orders/{reference}/attachments:
    parameters:
      - $ref: '#/components/parameters/Reference'
    post:
      tags: [Attachments]
      operationId: uploadAttachment
      summary: The file that would not fit in the JSON body
      description: |
        Multipart, one file per request, up to 100 MB — the same cap the staff
        uploader allows.

        Inline base64 on create covers the ordinary case and is capped at 5 MB a
        file so that the ~33% base64 inflation never approaches `post_max_size`.
        That split is what lets the inline cap stay low instead of being argued
        upward, and what lets a job and its photograph be ONE request that either
        happened or did not.

        Answers with the attachment alone, not the whole work order: uploading
        four photos in a row should not mean four full job payloads.

        The gate is shared with the work-order page, the customer portal and the
        legacy `?action2=upload`, and nothing is relaxed for the caller being
        authenticated. Note that the filename's EXTENSION is checked separately
        from the bytes — a JPEG named `payload.php` passes every content sniff and
        is refused anyway. See docs/legacy/upload-dir-rce.md for what that gate is
        defending. A refused file is never written.
      security:
        - bearerAuth: [work-orders:write]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: |
                    jpg, jpeg, png, gif, webp, heic, heif, mp4, m4v, mov, webm,
                    3gp or pdf. Max 100 MB.
      responses:
        '201':
          description: The stored attachment.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: '#/components/schemas/Attachment' }
              example:
                data:
                  id: 2
                  filename: riser.jpg
                  url: https://workorders.nz/files/n2-9b42825338-riser.jpg
                  mime: image/jpeg
                  size: 17791
                  kind: image
                  is_playable: false
                  source: api
                  ai_description: null
                  created_at: '2026-08-06T22:15:56Z'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationFailed' }
        '429': { $ref: '#/components/responses/TooManyRequests' }

# ==============================================================================
components:
# ==============================================================================

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        `Authorization: Bearer wo_…` — a per-COMPANY key minted by an
        administrator at `/settings/api-keys`. The plaintext is shown once and is
        never recoverable; only a sha256 digest is stored.

        The `wo_` prefix is not decoration: a bare 48-character random string in
        a log file, a support ticket or a public commit is unidentifiable,
        whereas `wo_…` is greppable and lets an integrator tell our key apart
        from the four others in their config file.

        **Bearer only.** The key never goes in the query string — URLs are logged
        by every proxy in the path, kept in browser history and forwarded in
        `Referer` headers.

        Abilities are named per operation in this document as OAuth-style scopes,
        which is the nearest thing OpenAPI's `http`/`bearer` scheme has to say
        them in. They are not OAuth scopes and there is no authorization flow —
        they are the fixed set granted to the key when it was minted:
        `work-orders:read`, `work-orders:write`, or `*` for both.

  parameters:

    Reference:
      name: reference
      in: path
      required: true
      description: |
        The `WO-XXXXXX` reference, as returned by create. Accepted
        case-insensitively and with the `WO-` prefix optional, so a client that
        stored it lower-cased or stripped the prefix because it looked like
        decoration is still quoting the right job.

        The reference rather than the row id, deliberately: an integrator has
        never seen our ids, and a global sequence tells every tenant how many jobs
        the whole platform has handled. It is an identifier and NOT a credential —
        knowing one authorises nothing, which is why it is short enough to read
        down the phone.
      schema:
        type: string
        examples: ['WO-P6J9CE']

    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: |
        Anything up to 255 characters, unique per work order — your own job id is
        usually the right thing. Optional, because refusing a work order over a
        matter of hygiene would be refusing a work order; strongly recommended,
        because it is what makes retrying safe.

        Same key + same body replays the stored 201. Same key + a different body
        is a 409. The fingerprint is canonicalised (object keys sorted
        recursively) so a client that reserialises its payload on retry is not
        told it has a conflict; inline attachment bytes are part of it, so a retry
        that quietly drops the photograph is correctly treated as a different
        request.
      schema:
        type: string
        maxLength: 255
        examples: ['fm-99213-2026-08-07']

    PerPage:
      name: per_page
      in: query
      description: |
        1–100. Above 100 is a 422, not a silent clamp — a client that asked for
        1000 and got 100 would page through the rest wrong.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 25

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

  headers:
    XRateLimitLimit:
      description: Requests permitted per minute for this key.
      schema: { type: integer, examples: [120] }
    XRateLimitRemaining:
      description: Requests left in the current window.
      schema: { type: integer, examples: [119] }
    RetryAfter:
      description: Seconds to wait before retrying.
      schema: { type: integer, examples: [60] }

  responses:

    Unauthorized:
      description: |
        Missing, malformed, unknown, revoked or expired key.

        All five get the same sentence on purpose. The distinctions are real and
        are in the server log where an operator can see them; they are withheld
        from the caller because learning that a key is REAL BUT EXPIRED — rather
        than simply wrong — is the difference between a key hunt that has found
        something and one that has not.
      headers:
        WWW-Authenticate:
          schema: { type: string, examples: ['Bearer'] }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: Invalid or missing API token.

    Forbidden:
      description: |
        The key is valid for this company but was not minted with the ability
        this operation needs.

        Named rather than hidden, unlike a foreign row's 404: the caller owns the
        account and can see their own keys in the settings screen, so telling
        them which ability is missing is what gets them to mint the right key
        instead of filing a bug against us.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: This API token is not permitted to work-orders:write.

    NotFound:
      description: |
        No such job **for you** — including one belonging to another company.

        A 403 there would confirm the job exists, and its existence is a fact
        about somebody else's business. No code implements this check: the tenant
        is bound at the door from the key, so the query that resolves the
        reference simply cannot see another company's row.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: No query results for model [App\Models\WorkOrder].

    IdempotencyConflict:
      description: |
        Two distinct cases, distinguished by the message.

        **The key has been used for a different body.** The client is reusing a
        counter and has moved on to the next job. Replaying would silently swallow
        a real fault report and return 201 for a work order we never created, and
        there is no honest way to guess which of the two was meant.

        **An earlier request with this key has not finished.** There is nothing to
        replay yet, and inventing an answer would mean speaking for a job that may
        still roll back. Retry a moment later.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            differentBody:
              summary: Same key, different request
              value:
                message: >-
                  This Idempotency-Key has already been used for a different
                  request. Use a new key for a new work order.
            stillProcessing:
              summary: The first request has not committed yet
              value:
                message: >-
                  A request with this Idempotency-Key is still being processed.
                  Retry in a moment.

    ValidationFailed:
      description: |
        The request was refused. `errors` maps a field name to a list of
        sentences; nested input uses dot paths, so `attachments.0.data` names the
        element the caller actually sent rather than a synthetic field they never
        wrote.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ValidationError' }
          examples:
            missingSubject:
              summary: The one required field
              value:
                message: The subject field is required.
                errors:
                  subject: ['The subject field is required.']
            unknownStatus:
              summary: A filter word we do not have
              value:
                message: >-
                  Unknown status: urgent. Valid statuses are new, assigned,
                  ongoing, completed, deleted.
                errors:
                  status:
                    - >-
                      Unknown status: urgent. Valid statuses are new, assigned,
                      ongoing, completed, deleted.
            refusedFile:
              summary: A file whose name and contents disagree
              value:
                message: That file type is not accepted. Photos, video and PDFs only.
                errors:
                  attachments.0.data:
                    - That file type is not accepted. Photos, video and PDFs only.
            oversizeFile:
              summary: Named per limit, because the remedy differs
              value:
                message: >-
                  `site-video.jpg` is 14.9 MB. Inline attachments are limited to
                  5 MB each — post larger files to
                  /api/v1/integration/work-orders/{reference}/attachments instead.
                errors:
                  attachments.0.data:
                    - >-
                      `site-video.jpg` is 14.9 MB. Inline attachments are limited
                      to 5 MB each — post larger files to
                      /api/v1/integration/work-orders/{reference}/attachments
                      instead.

    TooManyRequests:
      description: |
        Over 120 requests in a minute for this key. The number is a backstop
        against a loop, not a quota — a customer who genuinely needs more is a
        conversation rather than a 429.
      headers:
        Retry-After: { $ref: '#/components/headers/RetryAfter' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            message: Too Many Attempts.

  schemas:

    Error:
      type: object
      required: [message]
      properties:
        message:
          type: string
          description: A sentence fit to show a person.

    ValidationError:
      allOf:
        - $ref: '#/components/schemas/Error'
        - type: object
          required: [errors]
          properties:
            errors:
              type: object
              description: Field name (dot path for nested input) to a list of messages.
              additionalProperties:
                type: array
                items: { type: string }

    Ping:
      type: object
      required: [company, token, server_time]
      properties:
        company:
          type: object
          description: |
            Whose account this key acts for. Check it before posting a job into
            somebody else's after a copy-and-paste.
          required: [id, name]
          properties:
            id: { type: integer, examples: [1] }
            name: { type: string, examples: ['Demo Plumbing & Gas'] }
        token:
          type: object
          required: [name, abilities, expires_at]
          properties:
            name:
              type: string
              description: |
                What the office called this key. It is shown on every job the key
                raises, so "where did this come from" is answerable without a
                database.
              examples: ['Acme FM platform']
            abilities:
              type: array
              description: |
                Naming them here is what turns a later 403 into "ah, this key is
                read-only" rather than a bug report.
              items: { $ref: '#/components/schemas/Ability' }
            expires_at:
              type: [string, 'null']
              format: date-time
              examples: [null]
        server_time:
          type: string
          format: date-time
          description: |
            The server's own clock. Every timestamp this API emits is UTC with a
            `Z`, and an integrator whose box is an hour out should discover it
            here rather than in the shape of jobs filed at the wrong time.
          examples: ['2026-08-06T22:15:55Z']

    Ability:
      type: string
      enum:
        - work-orders:read
        - work-orders:write
        - '*'
      description: |
        Exact matches only — no prefixes and no wildcards inside a segment, so a
        verb added later cannot silently inherit an old key's permission. The
        settings screen offers Full access (`*`) and Read-only
        (`work-orders:read`).

    CreateWorkOrder:
      type: object
      required: [subject]
      properties:
        subject:
          type: string
          maxLength: 512
          description: |
            The one required field. A job with no subject is a row nobody can read
            on a board, and every caller has this much.
          examples: ['No hot water — Level 3 kitchen']
        body:
          type: [string, 'null']
          maxLength: 20000
          description: |
            Where the fault is actually described. Long, because a facilities
            platform will forward a whole email thread into it; capped, because a
            text column is not a file store. This is what the AI extraction pass
            reads.
        requester_name:
          type: [string, 'null']
          maxLength: 255
          description: Who reported the fault — a tenant, a site manager, a duty officer.
        requester_email:
          type: [string, 'null']
          format: email
          maxLength: 255
          description: |
            A customer record is found or synthesized from this. An empty string
            and an absent key mean the same thing.
        site_address:
          type: [string, 'null']
          maxLength: 512
          description: |
            Free text, kept as written. It is stored alongside any structured site
            the AI pass matches, because the two disagree often enough that a
            technician should see what the customer actually wrote.
        urgency:
          oneOf:
            - $ref: '#/components/schemas/Urgency'
            - type: 'null'
          description: |
            Optional. Omit it and the extraction pass reads one out of `body`;
            state it and the pass has a value to weigh rather than a blank. Sending
            one does not switch the pass off.
        external_reference:
          type: [string, 'null']
          maxLength: 128
          description: |
            YOUR id for this job. Never used as ours — it is not unique, not ours,
            and not something to key on — but it is what makes a support call
            answerable in both directions: they quote theirs, we find ours. It is
            recorded on the job's first timeline event.
          examples: ['FM-99213']
        attachments:
          type: array
          maxItems: 10
          description: |
            Up to 10 files, 5 MB each and 15 MB in total once decoded. Anything
            larger goes to `POST /work-orders/{reference}/attachments`.

            Inline base64 is allowed here and refused by the field app's API
            because every term of the arithmetic differs: the caps are an order of
            magnitude lower, and the caller is a server on a wire rather than a
            phone on a rural cell. What it buys is that a job and its photograph
            are one request that either happened or did not — which is what lets
            the idempotency key cover the file as well as the job.
          items: { $ref: '#/components/schemas/InlineAttachment' }

    InlineAttachment:
      type: object
      required: [filename, data]
      properties:
        filename:
          type: string
          maxLength: 255
          description: |
            Must carry a real extension. The extension is checked separately from
            the bytes — a JPEG named `payload.php` passes every content sniff and
            is refused anyway.
          examples: ['cylinder.jpg']
        content_type:
          type: [string, 'null']
          maxLength: 255
          description: |
            Optional, and treated as your CLAIM: the bytes are sniffed for the
            type actually recorded.
          examples: ['image/jpeg']
        data:
          type: string
          description: |
            Standard base64. A `data:` URI prefix is tolerated — it is not the
            documented shape, but it is what a browser's `FileReader` and half the
            HTTP clients in the world produce, and refusing it would fail a
            request whose meaning is not in doubt.
          examples: ['/9j/4AAQSkZJRgABAQAA…']

    WorkOrder:
      type: object
      description: |
        One shape for list, show and create. The only difference is `events`,
        present on show and absent elsewhere — a page of 25 jobs is not the place
        for 25 histories. Nothing else is conditional, so one model covers all
        three.
      required:
        [id, reference, status, source, subject, body, from, customer, site,
         assignee, created_at, updated_at]
      properties:
        id:
          type: integer
          description: |
            Our row id. A global sequence across every tenant — prefer `reference`
            for anything you store or show.
        reference:
          type: string
          description: |
            `WO-XXXXXX`. The identifier this API speaks in, and the one the
            customer reads down the phone. Store this.
          examples: ['WO-P6J9CE']
        status: { $ref: '#/components/schemas/Status' }
        status_label:
          type: [string, 'null']
          description: The office's own word for the status.
          examples: ['New']
        source:
          type: [string, 'null']
          enum: [email, manual, api, portal, null]
          description: >-
            How the job got in. A job raised here is `api`; `portal` is one the
            customer raised themselves from their own portal, which means there
            is no message behind it.
        urgency:
          oneOf:
            - $ref: '#/components/schemas/Urgency'
            - type: 'null'
          description: Null until the extraction pass runs, if you did not send one.
        subject: { type: string }
        summary:
          type: [string, 'null']
          description: Written by the AI pass. Null until it runs.
        body:
          type: object
          description: |
            Three renderings of the request as it was written. Only `text` is set
            for a job raised through this API.
          properties:
            text: { type: [string, 'null'] }
            html: { type: [string, 'null'] }
            markdown: { type: [string, 'null'] }
        from:
          type: object
          description: |
            Who ASKED — a tenant, a site manager, whoever the caller named. Not the
            integration itself; the key's name is recorded on the created event
            instead, so the two are never confused. Kept alongside `customer`
            because the customer is who the work is FOR.
          properties:
            name: { type: [string, 'null'] }
            email: { type: [string, 'null'], format: email }
        customer:
          oneOf:
            - $ref: '#/components/schemas/Customer'
            - type: 'null'
        site:
          oneOf:
            - $ref: '#/components/schemas/Site'
            - type: 'null'
          description: A structured site, when one has been matched. Null is normal.
        site_address:
          type: [string, 'null']
          description: The free text as sent, kept even when `site` is set.
        assignee:
          oneOf:
            - $ref: '#/components/schemas/User'
            - type: 'null'
        hours_attending:
          type: [number, 'null']
          description: Decimal hours, recorded by the technician on assignment.
        minutes_on_site:
          type: [integer, 'null']
          description: >-
            How long the technician was actually on site, in minutes, always a
            multiple of 15. Not `hours_attending` in another unit: that one is
            the estimate given when accepting the job. Null means it was never
            recorded, which is the normal case on jobs completed from the older
            field app — treat it as absent, not as zero.
        suggested_minutes_on_site:
          type: [integer, 'null']
          description: >-
            What a field app should open its time-on-site control at, computed
            fresh on every read and never stored. Read-only, and of no use to an
            integration; it is here because this payload is shared with the
            field app's API. See docs/api/v1.md.
        completed_note: { type: [string, 'null'] }
        health_note:
          type: [string, 'null']
          description: A health-and-safety observation recorded from the field.
        deleted_reason: { type: [string, 'null'] }
        accepted_at: { type: [string, 'null'], format: date-time }
        completed_at: { type: [string, 'null'], format: date-time }
        created_at: { type: string, format: date-time }
        updated_at:
          type: string
          format: date-time
          description: The cursor for `?updated_since=`, compared against exactly this column.
        attachments:
          type: array
          items: { $ref: '#/components/schemas/Attachment' }
        events:
          type: array
          description: Show only.
          items: { $ref: '#/components/schemas/WorkOrderEvent' }

    Status:
      type: [string, 'null']
      enum: [new, assigned, ongoing, completed, deleted, null]
      description: |
        `deleted` rows are hidden from the list unless `status` names them
        explicitly. This API cannot set any of these — see the description at the
        top of this document.

    Urgency:
      type: string
      enum: [low, normal, high, emergency]

    Customer:
      type: object
      properties:
        id: { type: integer }
        name: { type: [string, 'null'] }
        email: { type: [string, 'null'], format: email }
        phone: { type: [string, 'null'] }
        is_synthesized:
          type: boolean
          description: |
            True when the record was created from an email address alone rather
            than entered by the office.

    Site:
      type: object
      description: A structured place, with coordinates when they are known.
      properties:
        id: { type: integer }
        label: { type: [string, 'null'] }
        address: { type: [string, 'null'] }
        suburb: { type: [string, 'null'] }
        city: { type: [string, 'null'] }
        postcode: { type: [string, 'null'] }
        lat: { type: [number, 'null'] }
        lng: { type: [number, 'null'] }

    User:
      type: object
      properties:
        id: { type: integer }
        name: { type: [string, 'null'] }
        email: { type: [string, 'null'], format: email }
        phone: { type: [string, 'null'] }
        role: { type: [string, 'null'], examples: ['technician'] }
        timezone: { type: [string, 'null'] }
        is_active: { type: boolean }

    WorkOrderEvent:
      type: object
      required: [id, type, note, data, actor, actor_name, created_at]
      properties:
        id: { type: integer }
        type:
          type: [string, 'null']
          enum:
            [created, assigned, ongoing_note, completed, deleted, reopened,
             ai_extracted, reply_drafted, imported, customer_merged,
             mail_suppressed, customer_replied, customer_uploaded, api_note, null]
        label:
          type: [string, 'null']
          description: The office's own word for the type.
        note: { type: [string, 'null'] }
        data:
          type: [object, 'null']
          additionalProperties: true
          description: |
            The structured extras an event chose to record — an ETA on an
            assignment, hours on a completion, the key's name on anything this API
            did. Free-form by design and varying by type: **read the keys you know
            and ignore the rest** rather than expecting a schema.
        actor:
          oneOf:
            - $ref: '#/components/schemas/User'
            - type: 'null'
          description: |
            Null for system events — ingest, the AI pass, and everything this API
            does. Always present as a key, so a client never has to special-case
            its absence.
        actor_name:
          type: string
          description: |
            `"System"` when there is no actor. Never null, so there is always
            something to render.
          examples: ['System']
        created_at: { type: string, format: date-time }

    Attachment:
      type: object
      properties:
        id: { type: integer }
        filename: { type: string }
        url:
          type: string
          format: uri
          description: |
            **Unauthenticated, and the name IS the credential** — an HMAC-derived
            token makes the corpus unenumerable. Treat the URL as a secret; do not
            put it somewhere public.
        mime: { type: [string, 'null'] }
        size: { type: [integer, 'null'], description: Bytes. }
        kind:
          type: [string, 'null']
          description: |
            `image`, `video`, `document`. Here so a client can choose a thumbnail,
            a player or a download row without reimplementing our mime guessing.
        is_playable:
          type: boolean
          description: |
            Whether a browser will actually decode it. `.avi` is video that none
            will — this is how you find that out.
        source:
          type: [string, 'null']
          enum: [email, staff_upload, customer_upload, legacy_import, api, null]
          description: |
            A file sent through this API is `api` — not `staff_upload` and not
            `customer_upload`. It came from a customer's SYSTEM, under a key the
            office issued and can revoke, and "who put this here" is a different
            answer again from either of those.
        ai_description:
          type: [string, 'null']
          description: |
            What the AI pass made of the picture. Null is the normal state —
            nothing is described until it is looked at.
        created_at: { type: string, format: date-time }

    PaginationEnvelope:
      type: object
      description: |
        Laravel's standard envelope. Follow `links.next` rather than incrementing
        `page`: it carries the filters forward for you.
      required: [links, meta]
      properties:
        links:
          type: object
          properties:
            first: { type: [string, 'null'], format: uri }
            last: { type: [string, 'null'], format: uri }
            prev: { type: [string, 'null'], format: uri }
            next: { type: [string, 'null'], format: uri }
        meta:
          type: object
          properties:
            current_page: { type: integer }
            from: { type: [integer, 'null'] }
            last_page: { type: integer }
            path: { type: string, format: uri }
            per_page: { type: integer }
            to: { type: [integer, 'null'] }
            total: { type: integer }
            links:
              type: array
              description: |
                The numbered page list, for rendering a pager. Safe to ignore.
              items:
                type: object
                properties:
                  url: { type: [string, 'null'], format: uri }
                  label: { type: string }
                  page: { type: [integer, 'null'] }
                  active: { type: boolean }
