openapi: 3.0.3
info:
  title: DroneFleet — Drone-as-First-Responder (DFR) API
  version: "1.0"
  description: >
    REST + real-time API for a CAD / dispatch system to launch a drone to an incident,
    track it live, receive on-scene detections, and keep a synchronized incident log.
    Field shapes verified against the running API (2026-08-05). Companion narrative:
    DroneFleet-DFR-API.md. Real-time telemetry/targets are also broadcast over SignalR
    at /hubs/fleet (not expressible in OpenAPI) and pushed via webhooks (see the guide).
    Supported aircraft: MAVLink-compatible autopilots only (ArduPilot / PX4 / Pixhawk-class);
    non-MAVLink drones (DJI, or vendor-SDK-only platforms like Skydio / BRINC) need a vendor adapter.
servers:
  - url: https://dronefleet-api.azurewebsites.net/api/v1
    description: Production (Azure)
  - url: http://localhost:5220/api/v1
    description: Local dev

security:
  - BasicAuth: []      # HTTP Basic  base64(clientId:clientSecret)
  - ApiKeyAuth: []     # X-Api-Key (clientId, with X-Api-Secret) or legacy single secret
  - BearerAuth: []

tags:
  - name: Console
  - name: Auth
  - name: Fleet
  - name: Missions
  - name: Telemetry
  - name: Targets
  - name: IncidentLog
  - name: Airspace
  - name: Config
  - name: Webhooks

paths:
  # NOTE: /console is served at the SERVER ROOT (…/console), not under /api/v1.
  /console:
    servers:
      - url: https://dronefleet-api.azurewebsites.net
      - url: http://localhost:5220
    get:
      tags: [Console]
      summary: The DFR operator interface (HTML), served same-origin by the API
      description: >
        The DroneFleet ground-control view (same one the dispatch map launches). Open in a
        browser (window.open / iframe) with query params clientId, clientSecret, source
        (CFS|UNIT), caseNo, label, addr, lat, lon, priority, callsign, vehicleId.
        See DroneFleet-DFR-API.md §5.8.
      security: []
      responses:
        "200":
          description: The operator console page
          content:
            text/html:
              schema: { type: string }

  /auth/login:
    post:
      tags: [Auth]
      summary: Exchange email + password for a 30-day session token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email: { type: string, example: pilot@dronefleet.dev }
                password: { type: string, example: "Passw0rd!" }
      responses:
        "200":
          description: Session issued
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  expiresUtc: { type: string, format: date-time }
                  user: { type: object }
                  tenant: { type: object }
        "401": { $ref: '#/components/responses/Error' }

  /api-keys:
    post:
      tags: [Auth]
      summary: Mint a machine credential (X-Api-Key). Requires an ORG_ADMIN bearer token.
      security:
        - BearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [label]
              properties:
                label: { type: string, example: "City CAD - DFR" }
                scopes: { type: string, description: '"*" (default) or a role name', example: "*" }
                resourceGroup: { type: string, nullable: true }
                expiresInDays: { type: integer, nullable: true, example: 365 }
      responses:
        "201":
          description: Credential created — `clientSecret` shown once
          content:
            application/json:
              schema:
                type: object
                properties:
                  apiKeyId: { type: string }
                  clientId: { type: string, example: "dfc_8dd8863f60e0c6dc581ad741", description: "PUBLIC id — safe to store/log" }
                  clientSecret: { type: string, example: "dfk_…", description: "shown ONCE" }
                  key: { type: string, description: "legacy alias of clientSecret (single-key X-Api-Key mode)" }
                  label: { type: string }
                  scopes: { type: string }
                  status: { type: string, example: ACTIVE }
                  warning: { type: string }
        "401": { $ref: '#/components/responses/Error' }
        "403": { $ref: '#/components/responses/Error' }

  /api-keys/{id}/rotate-secret:
    post:
      tags: [Auth]
      summary: Rotate a credential's secret (same clientId). Requires ORG_ADMIN.
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200":
          description: New secret issued (clientId unchanged), shown once
          content:
            application/json:
              schema:
                type: object
                properties:
                  clientId: { type: string }
                  clientSecret: { type: string, description: "the NEW secret — shown once" }
                  warning: { type: string }
        "401": { $ref: '#/components/responses/Error' }
        "403": { $ref: '#/components/responses/Error' }
        "404": { $ref: '#/components/responses/Error' }
        "409": { $ref: '#/components/responses/Error' }

  /vehicles:
    get:
      tags: [Fleet]
      summary: List drones in the tenant
      responses:
        "200":
          description: Vehicle list
          content:
            application/json:
              schema:
                type: object
                properties:
                  vehicles:
                    type: array
                    items: { $ref: '#/components/schemas/Vehicle' }

  /vehicles/{id}:
    get:
      tags: [Fleet]
      summary: Get one drone
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Vehicle
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Vehicle' }
        "404": { $ref: '#/components/responses/Error' }

  /vehicles/{id}/telemetry:
    get:
      tags: [Telemetry]
      summary: Telemetry history for a drone (newest first)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: from, in: query, required: false, schema: { type: string, format: date-time } }
        - { name: to, in: query, required: false, schema: { type: string, format: date-time } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 200, maximum: 2000 } }
      responses:
        "200":
          description: Samples
          content:
            application/json:
              schema:
                type: object
                properties:
                  vehicleId: { type: string }
                  count: { type: integer }
                  samples:
                    type: array
                    items: { $ref: '#/components/schemas/TelemetrySample' }
        "404": { $ref: '#/components/responses/Error' }

  /telemetry:
    post:
      tags: [Telemetry]
      summary: Ingest a telemetry sample (aircraft → API; CAD normally reads, not writes)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [vehicleId, lat, lon, altM, headingDeg, groundSpeedMs, battPct]
              properties:
                vehicleId: { type: string }
                missionId: { type: string, nullable: true }
                lat: { type: number }
                lon: { type: number }
                altM: { type: number }
                headingDeg: { type: number }
                groundSpeedMs: { type: number }
                battPct: { type: number }
                tsUtc: { type: string, format: date-time, nullable: true }
      responses:
        "202": { description: Accepted (broadcast over SignalR) }
        "400": { $ref: '#/components/responses/Error' }
        "404": { $ref: '#/components/responses/Error' }

  /missions:
    get:
      tags: [Missions]
      summary: List missions
      parameters:
        - { name: projectId, in: query, required: false, schema: { type: string }, description: "numeric id, or 'none'" }
      responses:
        "200":
          description: Mission summaries
          content:
            application/json:
              schema:
                type: object
                properties:
                  missions:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        projectId: { type: string, nullable: true }
                        name: { type: string }
                        status: { $ref: '#/components/schemas/MissionStatus' }
                        waypointCount: { type: integer }
                        createdUtc: { type: string, format: date-time }
    post:
      tags: [Missions]
      summary: Create a DFR flight plan (deploy step 1)
      description: >
        Every waypoint is validated: altM ≤ 120 (~400 ft) and lon/lat within the demo
        geofence [-86.83, 33.40, -86.74, 33.49]. New missions start in DRAFT.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [waypoints]
              properties:
                name: { type: string, example: "DFR - 123 Main St (Priority 1)" }
                projectId: { type: integer, nullable: true }
                waypoints:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/WaypointInput' }
      responses:
        "201":
          description: Mission created (DRAFT)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Mission' }
        "400": { $ref: '#/components/responses/Error' }
        "401": { $ref: '#/components/responses/Error' }

  /missions/{id}:
    get:
      tags: [Missions]
      summary: Get a mission (with waypoints)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Mission
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Mission' }
        "404": { $ref: '#/components/responses/Error' }
    delete:
      tags: [Missions]
      summary: Delete a mission (with its waypoints, targets and incident events)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "204": { description: Deleted }
        "500": { $ref: '#/components/responses/Error' }

  /missions/{id}/start:
    post:
      tags: [Missions]
      summary: Launch the drone (mission → FLYING)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Started
          content:
            application/json:
              schema:
                type: object
                properties:
                  missionId: { type: string }
                  status: { $ref: '#/components/schemas/MissionStatus' }
        "404": { $ref: '#/components/responses/Error' }

  /missions/{id}/complete:
    post:
      tags: [Missions]
      summary: Clear the call (mission → COMPLETE; drone → IDLE)
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: Completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  missionId: { type: string }
                  status: { $ref: '#/components/schemas/MissionStatus' }
        "404": { $ref: '#/components/responses/Error' }

  /targets:
    post:
      tags: [Targets]
      summary: Record an on-scene detection (fires SignalR `target` + webhook `target.detected`)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [type, lat, lon]
              properties:
                missionId: { type: string, nullable: true }
                type: { $ref: '#/components/schemas/TargetType' }
                confidence: { type: number, example: 0.91 }
                lat: { type: number }
                lon: { type: number }
                tsUtc: { type: string, format: date-time, nullable: true }
      responses:
        "202":
          description: Accepted
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Target' }
        "400": { $ref: '#/components/responses/Error' }
        "404": { $ref: '#/components/responses/Error' }

  /targets/{id}:
    get:
      tags: [Targets]
      summary: Get a target
      parameters: [ { name: id, in: path, required: true, schema: { type: string } } ]
      responses:
        "200":
          description: Target
          content: { application/json: { schema: { $ref: '#/components/schemas/Target' } } }
        "404": { $ref: '#/components/responses/Error' }

  /missions/{missionId}/targets:
    get:
      tags: [Targets]
      summary: List a mission's detections
      parameters: [ { name: missionId, in: path, required: true, schema: { type: string } } ]
      responses:
        "200":
          description: Targets
          content:
            application/json:
              schema:
                type: object
                properties:
                  targets:
                    type: array
                    items: { $ref: '#/components/schemas/Target' }

  /missions/{missionId}/events:
    get:
      tags: [IncidentLog]
      summary: Get the incident log (chronological)
      parameters: [ { name: missionId, in: path, required: true, schema: { type: string } } ]
      responses:
        "200":
          description: Events
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items: { $ref: '#/components/schemas/IncidentEvent' }
    post:
      tags: [IncidentLog]
      summary: Append an incident-log entry (fires SignalR `incidentEvent`)
      description: >
        Appends to the incident log. If `kind` is a CFS-closing kind (CLOSED, CFS_CLOSED,
        CALL_CLOSED, CLEARED, CANCELLED, CANCELED, COMPLETE) the mission is ALSO transitioned
        to COMPLETE and any drone that flew it returns to IDLE — a `missionStatus` update is
        broadcast. Confirm via GET /missions/{id}. Any other kind only logs.
      parameters: [ { name: missionId, in: path, required: true, schema: { type: string } } ]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [kind]
              properties:
                kind: { type: string, example: ON_STATION, description: "e.g. DEPLOYED, EN_ROUTE, ON_STATION, TARGET_DETECTED, RTB. Closing kinds (CLOSED, CFS_CLOSED, CALL_CLOSED, CLEARED, CANCELLED, CANCELED, COMPLETE) also complete the mission." }
                message: { type: string, nullable: true }
                lat: { type: number, nullable: true }
                lon: { type: number, nullable: true }
                tsUtc: { type: string, format: date-time, nullable: true }
      responses:
        "201":
          description: Created
          content: { application/json: { schema: { $ref: '#/components/schemas/IncidentEvent' } } }
        "400": { $ref: '#/components/responses/Error' }
        "404": { $ref: '#/components/responses/Error' }

  /missions/{missionId}/video:
    get:
      tags: [IncidentLog]
      summary: Live video descriptor (placeholder; WebRTC in production)
      parameters: [ { name: missionId, in: path, required: true, schema: { type: string } } ]
      responses:
        "200":
          description: Stream descriptor
          content:
            application/json:
              schema:
                type: object
                properties:
                  missionId: { type: string }
                  status: { type: string, example: SIMULATED }
                  streamKind: { type: string, example: SIMULATED }
                  note: { type: string }

  /airspace-auth/facility-map:
    get:
      tags: [Airspace]
      summary: UAS Facility Map ceiling for a point
      parameters:
        - { name: lat, in: query, required: true, schema: { type: number } }
        - { name: lon, in: query, required: true, schema: { type: number } }
      responses:
        "200":
          description: Grid cell
          content:
            application/json:
              schema:
                type: object
                properties:
                  lat: { type: number }
                  lon: { type: number }
                  gridCeilingFt: { type: integer }
                  facility: { type: string, nullable: true }
                  provider: { type: string }

  /airspace-auth:
    post:
      tags: [Airspace]
      summary: Create (and optionally submit) a LAANC authorization request
      parameters:
        - { name: submit, in: query, required: false, schema: { type: boolean, default: false } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [lat, lon, requestedCeilingFt]
              properties:
                lat: { type: number }
                lon: { type: number }
                requestedCeilingFt: { type: integer }
                radiusM: { type: number, nullable: true }
                missionId: { type: integer, nullable: true }
                windowStartUtc: { type: string, format: date-time, nullable: true }
                windowEndUtc: { type: string, format: date-time, nullable: true }
                notes: { type: string, nullable: true }
      responses:
        "201":
          description: Authorization record (status AUTHORIZED | FURTHER_COORDINATION | DENIED | DRAFT)
          content: { application/json: { schema: { type: object } } }
        "400": { $ref: '#/components/responses/Error' }

  /config/geofence:
    get:
      tags: [Config]
      summary: Get the tenant's effective geofence / operating area (stored, or defaults)
      responses:
        "200":
          description: Effective geofence
          content: { application/json: { schema: { $ref: '#/components/schemas/GeofenceConfig' } } }
    put:
      tags: [Config]
      summary: Set the operating area — box or polygon (ORG_ADMIN or ENGINEER)
      description: >
        Supply EITHER bounds (box) OR a polygon ring of >= 3 [lon,lat] vertices. enabled
        defaults true; maxAltM defaults 120 and is capped at 400. A polygon's bounding box
        is computed and returned in minLon..maxLat. Validation errors return 400
        BAD_BOUNDS / BAD_POLYGON / BAD_ALTITUDE. Takes effect on the next mission.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                enabled: { type: boolean, default: true }
                polygon:
                  type: array
                  description: "Exterior ring [[lon,lat],...] (>= 3 vertices) for a POLYGON area. If present, bounds are ignored."
                  items: { type: array, items: { type: number }, minItems: 2, maxItems: 2 }
                  example: [[-86.83, 33.40], [-86.74, 33.40], [-86.785, 33.49]]
                minLon: { type: number, example: -86.95 }
                minLat: { type: number, example: 33.30 }
                maxLon: { type: number, example: -86.55 }
                maxLat: { type: number, example: 33.60 }
                maxAltM: { type: number, maximum: 400, example: 120 }
                label: { type: string, nullable: true, example: "City of Homewood" }
      responses:
        "200":
          description: Saved
          content: { application/json: { schema: { $ref: '#/components/schemas/GeofenceConfig' } } }
        "400": { $ref: '#/components/responses/Error' }
        "401": { $ref: '#/components/responses/Error' }
        "403": { $ref: '#/components/responses/Error' }
    delete:
      tags: [Config]
      summary: Revert to the platform default geofence (ORG_ADMIN or ENGINEER)
      responses:
        "200":
          description: Reverted (now-effective = defaults)
          content: { application/json: { schema: { $ref: '#/components/schemas/GeofenceConfig' } } }
        "401": { $ref: '#/components/responses/Error' }
        "403": { $ref: '#/components/responses/Error' }

  /webhooks:
    post:
      tags: [Webhooks]
      summary: Subscribe a callback URL (e.g. for target.detected)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url, events]
              properties:
                url: { type: string, format: uri }
                events:
                  type: array
                  items: { type: string }
                  example: ["target.detected"]
                secret: { type: string, nullable: true }
      responses:
        "201":
          description: Subscription created (signing `secret` shown once)
          content: { application/json: { schema: { type: object } } }
        "400": { $ref: '#/components/responses/Error' }
    get:
      tags: [Webhooks]
      summary: List subscriptions
      responses:
        "200":
          description: Subscriptions
          content: { application/json: { schema: { type: object } } }

components:
  securitySchemes:
    BasicAuth:
      type: http
      scheme: basic
      description: "HTTP Basic with the Client ID as username and the Client Secret as password."
    ApiKeyAuth:
      type: apiKey
      in: header
      name: X-Api-Key
      description: "Client ID in X-Api-Key + secret in X-Api-Secret; or a legacy single secret in X-Api-Key alone."
    BearerAuth:
      type: http
      scheme: bearer

  responses:
    Error:
      description: Error envelope
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string, example: GEOFENCE_VIOLATION }
            message: { type: string }
    MissionStatus:
      type: string
      enum: [DRAFT, UPLOADED, FLYING, COMPLETE]
    TargetType:
      type: string
      enum: [PERSON, VEHICLE, OTHER]
    Vehicle:
      type: object
      properties:
        id: { type: string }
        externalId: { type: string }
        name: { type: string }
        status: { type: string, enum: [IDLE, FLYING] }
        lastLat: { type: number, nullable: true }
        lastLon: { type: number, nullable: true }
        lastAltM: { type: number, nullable: true }
        battPct: { type: number, nullable: true }
        tsUtc: { type: string, format: date-time, nullable: true }
        supportedPayloads:
          type: array
          items: { type: string }
          example: ["LIDAR", "RGB", "ZOOM"]
    WaypointInput:
      type: object
      required: [seq, lat, lon, altM]
      properties:
        seq: { type: integer, example: 1 }
        lat: { type: number, example: 33.4405 }
        lon: { type: number, example: -86.789 }
        altM: { type: number, maximum: 120, example: 90 }
        holdSec: { type: integer, nullable: true, example: 30 }
    Mission:
      type: object
      properties:
        id: { type: string }
        externalId: { type: string }
        projectId: { type: string, nullable: true }
        name: { type: string }
        status: { $ref: '#/components/schemas/MissionStatus' }
        createdUtc: { type: string, format: date-time }
        waypoints:
          type: array
          items: { $ref: '#/components/schemas/WaypointInput' }
    TelemetrySample:
      type: object
      properties:
        id: { type: string }
        vehicleId: { type: string }
        missionId: { type: string, nullable: true }
        lat: { type: number }
        lon: { type: number }
        altM: { type: number }
        headingDeg: { type: number }
        groundSpeedMs: { type: number }
        battPct: { type: number }
        tsUtc: { type: string, format: date-time }
    Target:
      type: object
      properties:
        id: { type: string }
        missionId: { type: string, nullable: true }
        type: { $ref: '#/components/schemas/TargetType' }
        confidence: { type: number }
        lat: { type: number }
        lon: { type: number }
        tsUtc: { type: string, format: date-time }
    IncidentEvent:
      type: object
      properties:
        id: { type: string }
        missionId: { type: string }
        kind: { type: string }
        message: { type: string }
        lat: { type: number, nullable: true }
        lon: { type: number, nullable: true }
        tsUtc: { type: string, format: date-time }
    GeofenceConfig:
      type: object
      properties:
        configured: { type: boolean, description: "false = using platform defaults" }
        enabled: { type: boolean, description: "false = area check skipped; altitude ceiling still applies" }
        shape: { type: string, enum: [BBOX, POLYGON] }
        polygon:
          type: array
          nullable: true
          description: "Exterior ring [[lon,lat],...] when shape=POLYGON, else null"
          items: { type: array, items: { type: number } }
        minLon: { type: number, description: "box, or the polygon's bounding box" }
        minLat: { type: number }
        maxLon: { type: number }
        maxLat: { type: number }
        maxAltM: { type: number }
        maxAltCeilingM: { type: number, description: "hard cap the API enforces (400)" }
        label: { type: string, nullable: true }
        updatedUtc: { type: string, format: date-time, nullable: true }
