openapi: 3.0.3
info:
  title: CaveDevil API
  description: |
    REST API for **CaveMonkey** (Android field app) and the CaveDevil Map Manager web UI.

    Product display name: **CaveDevil Map Manager**  
    GitHub repo description: **CaveDevil API**  
    Header names keep the `CaveDevil` prefix for backward compatibility.

    **Preferred personal sync path:** `/api/v1/*`  
    **Machine-readable contract:** `GET /api/openapi` (this document)  
    **Discovery:** `GET /api/health`

    Contract version matches package `version` in health response.
  version: 0.3.0
  contact:
    name: CaveDevil Map Manager

servers:
  - url: "{baseUrl}"
    variables:
      baseUrl:
        default: http://10.0.2.2:3000
        description: Map Manager base URL (no trailing slash). Android emulator → host uses 10.0.2.2.

tags:
  - name: Discovery
  - name: Auth
  - name: PersonalSync
    description: CaveMonkey personal workspace (recommended)
  - name: Catalog
  - name: Media
    description: Map drawings and trip photos (multipart or presigned)
  - name: Inbox
  - name: LegacySurveys
    description: Legacy POST /api/surveys still supported
  - name: Firmware
    description: CaveDevil device firmware release discovery

paths:
  /api/health:
    get:
      tags: [Discovery]
      operationId: getHealth
      summary: Health + endpoint contract for CaveMonkey
      security: []
      responses:
        "200":
          description: Service metadata
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HealthResponse"

  /api/openapi:
    get:
      tags: [Discovery]
      operationId: getOpenApi
      summary: OpenAPI 3 YAML (this document)
      security: []
      responses:
        "200":
          description: YAML document
          content:
            application/yaml:
              schema:
                type: string

  /api/v1/firmware/latest:
    get:
      tags: [Firmware]
      operationId: getLatestFirmware
      summary: Latest published CaveDevil firmware release (paired app only)
      description: |
        Requires a valid field-app device token from pairing
        (`Authorization: Bearer cdv_dt_…` or `X-CaveDevil-Device`).
        Unauthenticated callers receive 401 and no signed download URL.
        Highest-semver release on the channel is returned (not merely newest
        createdAt). Absolute `downloadUrl` uses the canonical app origin;
        when signed S3 GET is enabled, both `downloadUrl` and legacy `url`
        are fresh short-lived signed URLs (never persisted signatures).
        Rate-limited per client IP.
      security:
        - deviceToken: []
      parameters:
        - name: channel
          in: query
          required: false
          schema:
            type: string
            enum: [stable, beta]
            default: stable
      responses:
        "200":
          description: Latest release, or `available: false` if none published yet
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/FirmwareLatestResponse"
        "401":
          description: Missing or invalid device token
        "429":
          description: Rate limit exceeded
        "503":
          description: Signed download URL unavailable (misconfiguration)

  /api/admin/firmware:
    get:
      tags: [FirmwareAdmin]
      operationId: listFirmwareReleases
      summary: List firmware releases (super-admin)
      description: Requires Cognito super-admin group.
      responses:
        "200":
          description: Release list
        "403":
          description: Super-admin only
        "429":
          description: Rate limit exceeded
    post:
      tags: [FirmwareAdmin]
      operationId: publishFirmwareRelease
      summary: Publish firmware binary (super-admin)
      description: |
        Raw octet-stream body (max 8 MiB, streamed hard cap). Server computes
        CRC32 + SHA-256. Version must be strict SemVer and strictly newer than
        the current channel latest. hardwareModel=pros3; hardwareRevisions
        empty or r1 only.
      parameters:
        - name: version
          in: query
          required: true
          schema:
            type: string
            example: "3.1.0"
        - name: channel
          in: query
          schema:
            type: string
            enum: [stable, beta]
        - name: hardwareModel
          in: query
          schema:
            type: string
            enum: [pros3]
        - name: hardwareRevisions
          in: query
          schema:
            type: string
            description: Empty (wildcard) or r1
        - name: mandatory
          in: query
          schema:
            type: boolean
        - name: minFirmware
          in: query
          schema:
            type: string
        - name: changelog
          in: query
          schema:
            type: string
            maxLength: 4096
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema:
              type: string
              format: binary
      responses:
        "201":
          description: Created
        "400":
          description: Validation / policy conflict
        "403":
          description: Super-admin only
        "413":
          description: Body exceeds 8 MiB
        "429":
          description: Rate limit exceeded
    delete:
      tags: [FirmwareAdmin]
      operationId: deleteFirmwareRelease
      summary: Delete firmware release catalog + object (super-admin)
      parameters:
        - name: id
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Deleted
        "403":
          description: Super-admin only
        "404":
          description: Not found
        "429":
          description: Rate limit exceeded

  /api/v1/device/pair:
    post:
      tags: [Auth]
      operationId: createDevicePairCode
      summary: Generate short connect code (signed-in web user)
      description: |
        Shown on /connect. User enters the code in CaveDevil App.
        Codes expire in ~10 minutes and are single-use.
      responses:
        "200":
          description: Pairing code
    get:
      tags: [Auth]
      operationId: listLinkedDevices
      summary: List devices linked to the current user
      responses:
        "200":
          description: Device list
    delete:
      tags: [Auth]
      operationId: revokeDevice
      summary: Revoke a linked device
      parameters:
        - name: deviceId
          in: query
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Revoked

  /api/v1/device/link:
    post:
      tags: [Auth]
      operationId: linkDeviceWithCode
      summary: App redeems pairing code (no prior auth)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  example: ABC123
                label:
                  type: string
      responses:
        "200":
          description: deviceToken + baseUrl
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  baseUrl:
                    type: string
                    example: https://www.cavedevil.app
                  deviceToken:
                    type: string
                  user:
                    type: object

  /api/v1/auth/me:
    get:
      tags: [Auth]
      operationId: getAuthMe
      summary: Diagnose authentication (always HTTP 200)
      description: |
        Use from CaveMonkey Settings → Test connection.
        `authenticated: false` plus `reason` explains 401 causes without throwing.
      security:
        - SessionHeader: []
        - ApiKeyHeader: []
        - BearerAuth: []
      responses:
        "200":
          description: Auth diagnosis
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/AuthMeResponse"

  /api/v1/sync/surveys:
    get:
      tags: [PersonalSync]
      operationId: listPersonalSurveys
      summary: List personal workspace surveys for the signed-in user
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      responses:
        "200":
          description: Personal survey summaries
          content:
            application/json:
              schema:
                type: object
                required: [ownerUserId, surveys]
                properties:
                  ownerUserId:
                    type: string
                  surveys:
                    type: array
                    items:
                      $ref: "#/components/schemas/SurveySummary"
        "401":
          $ref: "#/components/responses/Unauthorized"
    post:
      tags: [PersonalSync]
      operationId: uploadPersonalSurvey
      summary: Upload / update personal survey (CaveMonkey primary path)
      description: |
        Body may be the survey document **or** `{ "survey": { ... } }`.
        Keep `id` stable across re-uploads to update the same workspace entry.
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/SurveyDocument"
                - type: object
                  required: [survey]
                  properties:
                    survey:
                      $ref: "#/components/schemas/SurveyDocument"
      responses:
        "201":
          description: Saved to personal workspace
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PersonalUploadResponse"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "409":
          description: Conflict (owned by another user, submitted, or approved)
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorBody"

  /api/v1/sync/surveys/{id}:
    parameters:
      - $ref: "#/components/parameters/SurveyId"
    get:
      tags: [PersonalSync]
      operationId: getPersonalSurvey
      summary: Get full survey document (owner / collaborator)
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      responses:
        "200":
          description: Survey
          content:
            application/json:
              schema:
                type: object
                properties:
                  survey:
                    $ref: "#/components/schemas/SurveyDocument"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
    patch:
      tags: [PersonalSync]
      operationId: patchSurveyMeta
      summary: Update name / notes / team / declination
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name:
                  type: string
                notes:
                  type: string
                team:
                  type: string
                declinationDeg:
                  type: number
      responses:
        "200":
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  survey:
                    $ref: "#/components/schemas/SurveyDocument"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    post:
      tags: [PersonalSync]
      operationId: submitOrWithdrawSurvey
      summary: Submit to club archivist or withdraw
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [targetCaveId]
                  properties:
                    action:
                      type: string
                      enum: [submit]
                      default: submit
                    targetCaveId:
                      type: string
                      description: Club cave id from GET /api/v1/caves
                    targetClubId:
                      type: string
                    message:
                      type: string
                - type: object
                  required: [action]
                  properties:
                    action:
                      type: string
                      enum: [withdraw]
      responses:
        "200":
          description: Submitted or withdrawn
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  status:
                    $ref: "#/components/schemas/SurveyWorkflowStatus"
                  surveyId:
                    type: string
                  targetCaveId:
                    type: string
                  message:
                    type: string
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/v1/sync/surveys/{id}/share:
    parameters:
      - $ref: "#/components/parameters/SurveyId"
    get:
      tags: [PersonalSync]
      operationId: listCollaborators
      summary: List collaborators on a personal survey
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      responses:
        "200":
          description: Collaborators
          content:
            application/json:
              schema:
                type: object
                properties:
                  ownerUserId:
                    type: string
                  collaborators:
                    type: array
                    items:
                      $ref: "#/components/schemas/SurveyCollaborator"
                  canShare:
                    type: boolean
    post:
      tags: [PersonalSync]
      operationId: shareSurvey
      summary: Share personal survey (owner only)
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                email:
                  type: string
                  format: email
                userId:
                  type: string
                role:
                  type: string
                  enum: [viewer, editor]
                  default: editor
      responses:
        "200":
          description: Shared
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
    delete:
      tags: [PersonalSync]
      operationId: unshareSurvey
      summary: Remove collaborator (owner only)
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      parameters:
        - name: email
          in: query
          schema:
            type: string
        - name: userId
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Removed

  /api/v1/caves:
    get:
      tags: [Catalog]
      operationId: listSubmitCaves
      summary: List caves visible for submit target picker
      description: |
        Returns club caves the user can see. Use `id` as `targetCaveId` when submitting.
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      parameters:
        - name: clubId
          in: query
          schema:
            type: string
        - name: q
          in: query
          description: Name / id / region filter
          schema:
            type: string
      responses:
        "200":
          description: Cave list
          content:
            application/json:
              schema:
                type: object
                properties:
                  caves:
                    type: array
                    items:
                      $ref: "#/components/schemas/CaveSubmitTarget"
                  count:
                    type: integer
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/v1/inbox:
    get:
      tags: [Inbox]
      operationId: listInbox
      summary: Archivist inbox (editor/admin)
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      parameters:
        - name: clubId
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Submissions awaiting review
          content:
            application/json:
              schema:
                type: object
                properties:
                  submissions:
                    type: array
                    items:
                      $ref: "#/components/schemas/SurveySummary"
                  message:
                    type: string
    post:
      tags: [Inbox]
      operationId: reviewSubmission
      summary: Approve or reject a submitted survey
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  required: [action, surveyId]
                  properties:
                    action:
                      type: string
                      enum: [approve]
                    surveyId:
                      type: string
                    caveId:
                      type: string
                    notes:
                      type: string
                - type: object
                  required: [action, surveyId, notes]
                  properties:
                    action:
                      type: string
                      enum: [reject]
                    surveyId:
                      type: string
                    notes:
                      type: string
      responses:
        "200":
          description: Reviewed
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/surveys:
    post:
      tags: [LegacySurveys]
      operationId: postSurveyLegacy
      summary: Legacy upsert (prefer /api/v1/sync/surveys)
      description: |
        Supports:
        - `?mode=personal` → personal workspace (auth required)
        - default catalog upsert (optional `?caveId=`)
        Body: survey doc or `{ "survey": ... }`.
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
        - {}
      parameters:
        - name: mode
          in: query
          schema:
            type: string
            enum: [personal, workspace, catalog]
        - name: caveId
          in: query
          schema:
            type: string
        - name: createCave
          in: query
          schema:
            type: string
            enum: ["0", "1"]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - $ref: "#/components/schemas/SurveyDocument"
                - type: object
                  properties:
                    survey:
                      $ref: "#/components/schemas/SurveyDocument"
      responses:
        "201":
          description: Upserted
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"

  /api/surveys/{id}:
    parameters:
      - $ref: "#/components/parameters/SurveyId"
    get:
      tags: [Catalog]
      operationId: getSurveyCatalog
      summary: Full survey document (catalog or workspace if permitted)
      responses:
        "200":
          description: Survey JSON
        "404":
          $ref: "#/components/responses/NotFound"
    put:
      tags: [Catalog]
      operationId: putSurvey
      summary: Replace survey document (shots edit)
      description: Browser cookie or same mobile auth headers.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SurveyDocument"
      responses:
        "200":
          description: Saved
        "403":
          $ref: "#/components/responses/Forbidden"

  /api/surveys/{id}/export:
    get:
      tags: [Catalog]
      operationId: exportSurvey
      summary: Export Survex / Therion / LS stats
      parameters:
        - $ref: "#/components/parameters/SurveyId"
        - name: format
          in: query
          schema:
            type: string
            enum: [survex, svx, therion, th, stats, json]
            default: survex
      responses:
        "200":
          description: Text export or JSON stats

  /api/caves:
    get:
      tags: [Catalog]
      operationId: listCavesPublicApi
      summary: List caves (filtered by visibility for current user)
      responses:
        "200":
          description: Caves

  /api/caves/{id}:
    get:
      tags: [Catalog]
      operationId: getCave
      summary: Cave detail + maps + survey summaries
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
      responses:
        "200":
          description: Cave payload
        "404":
          $ref: "#/components/responses/NotFound"

  /api/caves/{id}/composite:
    get:
      tags: [Catalog]
      operationId: getCaveComposite
      summary: Multi-team composite geometry / Survex / Therion
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
        - name: format
          in: query
          schema:
            type: string
            enum: [json, survex, svx, therion, th]
        - name: surveys
          in: query
          description: Comma-separated survey ids to include
          schema:
            type: string
      responses:
        "200":
          description: Composite JSON or export text

  /api/clubs/{id}/export:
    get:
      tags: [Catalog]
      operationId: exportClubArchive
      summary: Club admin full-data ZIP archive
      description: |
        Requires **admin** role on the club (`club_admin`).
        Returns a ZIP with folders for club settings, members, invites,
        karsts, caves (joins + composites), full surveys (JSON + Survex/Therion/SVG),
        and media files. Device pairing secrets are never included.
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Club id
        - name: format
          in: query
          schema:
            type: string
            enum: [zip]
            default: zip
        - name: includeDerived
          in: query
          description: Set to `0` to omit Survex/Therion/SVG convenience files
          schema:
            type: string
        - name: redactInvites
          in: query
          description: Set to `1` to redact invite tokens
          schema:
            type: string
      responses:
        "200":
          description: ZIP archive
          content:
            application/zip:
              schema:
                type: string
                format: binary
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"

  /api/import:
    get:
      tags: [Catalog]
      operationId: listImportFormats
      summary: Supported import formats
      responses:
        "200":
          description: Format list
    post:
      tags: [Catalog]
      operationId: importSurveyFile
      summary: Import CSV / Compass / Survex / Therion / JSON
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
                format:
                  type: string
                  enum: [csv, compass, survex, therion, cavemonkey, auto]
      responses:
        "200":
          description: Imported survey

  /api/media:
    get:
      tags: [Media]
      operationId: listMedia
      summary: List media for a cave
      parameters:
        - name: caveId
          in: query
          schema:
            type: string
        - name: kind
          in: query
          schema:
            type: string
      responses:
        "200":
          description: Media list
    post:
      tags: [Media]
      operationId: uploadMedia
      summary: Multipart upload (or JSON with existing URL)
      description: Max 25MB via server body. Prefer presign for large files.
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      requestBody:
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file, caveId]
              properties:
                file:
                  type: string
                  format: binary
                caveId:
                  type: string
                title:
                  type: string
                mediaKind:
                  type: string
                  enum: [map_drawing, map_pdf, photo]
                mapKind:
                  type: string
      responses:
        "201":
          description: Media created

  /api/media/presign:
    post:
      tags: [Media]
      operationId: presignMediaUpload
      summary: Get signed PUT URL for direct upload
      description: |
        Returns `uploadUrl` + `headers` for a client PUT, then call `/api/media/complete`.
        With `CAVEDEVIL_STORAGE=local`, `uploadUrl` is `/api/media/direct?key=…`.
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [caveId, fileName, mimeType]
              properties:
                caveId:
                  type: string
                fileName:
                  type: string
                mimeType:
                  type: string
                mediaKind:
                  type: string
                expiresIn:
                  type: integer
      responses:
        "200":
          description: Presign payload
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok:
                    type: boolean
                  method:
                    type: string
                    example: PUT
                  uploadUrl:
                    type: string
                  key:
                    type: string
                  publicUrl:
                    type: string
                  headers:
                    type: object
                    additionalProperties:
                      type: string
                  expiresIn:
                    type: integer
                  complete:
                    type: object

  /api/media/complete:
    post:
      tags: [Media]
      operationId: completeMediaUpload
      summary: Register media after successful presigned PUT
      security:
        - SessionHeader: []
          ApiKeyHeader: []
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [caveId, url, title]
              properties:
                caveId:
                  type: string
                key:
                  type: string
                url:
                  type: string
                title:
                  type: string
                mimeType:
                  type: string
                mediaKind:
                  type: string
                mapKind:
                  type: string
                fileName:
                  type: string
                sizeBytes:
                  type: integer
                notes:
                  type: string
      responses:
        "201":
          description: Media registered

components:
  securitySchemes:
    SessionHeader:
      type: apiKey
      in: header
      name: X-CaveDevil-Session
      description: |
        Demo session preset: `solo` | `stc-surveyor` | `stc` | `stc-viewer` | `demo` | `admin`.
        Production: prefer Cognito Bearer JWT.
    ApiKeyHeader:
      type: apiKey
      in: header
      name: X-API-Key
      description: Required when server env `CAVEDEVIL_API_KEY` is set.
    BearerAuth:
      type: http
      scheme: bearer
      description: |
        - Demo: `Bearer stc-surveyor` or `Bearer <apiKey>:<session>`
        - Production: Cognito access/id JWT (`CAVEDEVIL_AUTH=cognito`)
    deviceToken:
      type: http
      scheme: bearer
      description: |
        Field-app device token from pairing (`cdv_dt_…`).
        Also accepted as header `X-CaveDevil-Device`.
        Required for `GET /api/v1/firmware/latest` (firmware images are not public).

  parameters:
    SurveyId:
      name: id
      in: path
      required: true
      schema:
        type: string
        format: uuid

  responses:
    Unauthorized:
      description: Missing/invalid credentials
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AuthErrorBody"
    Forbidden:
      description: Authenticated but not allowed
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    BadRequest:
      description: Invalid body
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"

  schemas:
    ErrorBody:
      type: object
      properties:
        error:
          type: string

    AuthErrorBody:
      allOf:
        - $ref: "#/components/schemas/ErrorBody"
        - type: object
          properties:
            reason:
              type: string
              enum:
                - missing_credentials
                - invalid_session
                - guest_not_allowed
                - missing_api_key
                - invalid_api_key
            allowedSessions:
              type: array
              items:
                type: string
            hint:
              type: string

    HealthResponse:
      type: object
      properties:
        service:
          type: string
          example: CaveDevil
        product:
          type: string
          example: CaveDevil Map Manager
        version:
          type: string
          example: "0.2.0"
        caveMonkeySchema:
          type: array
          items:
            type: integer
        caveMonkey:
          type: object
          description: Mobile integration map
          additionalProperties: true
        endpoints:
          type: object
          additionalProperties: true

    FirmwareLatestResponse:
      type: object
      required: [available]
      properties:
        available:
          type: boolean
        version:
          type: string
          example: "3.1.0"
        notes:
          type: string
          nullable: true
          description: Deprecated alias of changelog
        changelog:
          type: string
          nullable: true
        sizeBytes:
          type: integer
        crc32:
          type: string
          description: Uppercase hex, matches the on-device rename_firmware.py check
          example: "1C98CFBD"
        sha256:
          type: string
          description: Lowercase hex
        url:
          type: string
          description: |
            Deprecated. Relative path in unsigned mode; same as downloadUrl when
            signed GET is enabled.
        downloadUrl:
          type: string
          description: Absolute https URL (canonical origin or fresh signed GET)
        hardwareModel:
          type: string
          example: pros3
        hardwareRevisions:
          type: array
          items:
            type: string
          description: Empty array means all revisions of hardwareModel
        minimumFirmwareVersion:
          type: string
          nullable: true
        releaseChannel:
          type: string
          enum: [stable, beta]
        mandatory:
          type: boolean
        publishedAt:
          type: string
          format: date-time

    AuthMeResponse:
      type: object
      properties:
        ok:
          type: boolean
        authenticated:
          type: boolean
        reason:
          type: string
          nullable: true
        user:
          nullable: true
          type: object
          properties:
            id:
              type: string
            displayName:
              type: string
            email:
              type: string
            memberships:
              type: object
              additionalProperties:
                type: string
        apiKeyRequired:
          type: boolean
        allowedSessions:
          type: array
          items:
            type: string
        headersExpected:
          type: object
          additionalProperties:
            type: string

    SurveyWorkflowStatus:
      type: string
      enum: [personal, submitted, approved, rejected]

    ShotType:
      type: string
      enum: [centreline, splay, backsight]

    Shot:
      type: object
      required:
        - id
        - fromStation
        - distanceM
        - azimuthDeg
        - inclinationDeg
        - type
        - createdAt
      properties:
        id:
          type: string
        fromStation:
          type: string
        toStation:
          type: string
          nullable: true
        distanceM:
          type: number
        azimuthDeg:
          type: number
          description: 0 = North, clockwise
        inclinationDeg:
          type: number
          description: +up / −down
        type:
          $ref: "#/components/schemas/ShotType"
        comment:
          type: string
        deleted:
          type: boolean
        createdAt:
          type: string
          format: date-time
        shotQuality:
          type: number
        instrumentId:
          type: string

    SurveyDocument:
      type: object
      required: [id, name, shots]
      properties:
        schemaVersion:
          type: integer
          enum: [1, 2]
          default: 2
        id:
          type: string
          description: Stable UUID from device
        name:
          type: string
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
        team:
          type: string
        notes:
          type: string
        declinationDeg:
          type: number
        currentFrom:
          type: string
        nextStation:
          type: string
        shots:
          type: array
          items:
            $ref: "#/components/schemas/Shot"
        clubId:
          type: string
        caveId:
          type: string
        ownerUserId:
          type: string
        status:
          $ref: "#/components/schemas/SurveyWorkflowStatus"
        sourceApp:
          type: string
          example: CaveMonkey

    SurveySummary:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        createdAt:
          type: string
        updatedAt:
          type: string
        shotCount:
          type: integer
        team:
          type: string
        caveId:
          type: string
        clubId:
          type: string
        hasDigitalSurvey:
          type: boolean
        ownerUserId:
          type: string
        status:
          $ref: "#/components/schemas/SurveyWorkflowStatus"
        targetCaveId:
          type: string
        targetClubId:
          type: string
        submittedAt:
          type: string
        reviewNotes:
          type: string
        sharedRole:
          type: string
          enum: [viewer, editor]
        collaboratorCount:
          type: integer

    SurveyCollaborator:
      type: object
      properties:
        userId:
          type: string
        email:
          type: string
        displayName:
          type: string
        role:
          type: string
          enum: [viewer, editor]
        invitedAt:
          type: string

    PersonalUploadResponse:
      type: object
      properties:
        ok:
          type: boolean
        workspace:
          type: string
          example: personal
        surveyId:
          type: string
        status:
          $ref: "#/components/schemas/SurveyWorkflowStatus"
        summary:
          $ref: "#/components/schemas/SurveySummary"
        message:
          type: string

    CaveSubmitTarget:
      type: object
      properties:
        id:
          type: string
        name:
          type: string
        clubId:
          type: string
        clubShortName:
          type: string
          nullable: true
        clubName:
          type: string
          nullable: true
        region:
          type: string
          nullable: true
        country:
          type: string
          nullable: true
        karstSystemId:
          type: string
          nullable: true
        visibility:
          type: string
        surveyCount:
          type: integer
        canSubmit:
          type: boolean
