openapi: 3.1.0
info:
  title: Microwaveprop Public API
  version: "1.0.0"
  summary: REST API for QSO submission, beacon management, and propagation queries.
  description: |
    Public REST API for the NTMS microwave propagation prediction service.
    Implements every action a regular signed-in user can take on the
    website (submit contacts and beacons, manage beacon monitors,
    manage their own API tokens, query propagation scores), plus public
    read-only endpoints for contacts, beacons, profiles, and scores.

    Admin-only operations (user management, beacon approval, contact
    moderation) are intentionally **not** exposed here.

    See `docs/api/README.md` for prose, examples, and the stability
    promise.
  contact:
    name: NTMS
    url: https://w5hn.org/

servers:
  - url: https://prop.w5isp.com/api/v1
    description: Production
  - url: http://localhost:4000/api/v1
    description: Local development

security:
  - bearerAuth: []

tags:
  - name: auth
  - name: me
  - name: contacts
  - name: beacons
  - name: monitors
  - name: scores
  - name: profiles

paths:
  /auth/tokens:
    post:
      tags: [auth]
      summary: Issue a long-lived bearer token
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/AuthRequest" }
      responses:
        "201":
          description: Token created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/AuthResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /me:
    get:
      tags: [me]
      summary: Current user profile
      responses:
        "200":
          description: Profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
    patch:
      tags: [me]
      summary: Update home QTH
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HomeQthRequest" }
      responses:
        "200":
          description: Updated profile
          content:
            application/json:
              schema: { $ref: "#/components/schemas/MeResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /me/contacts:
    get:
      tags: [me]
      summary: My QSOs
      responses:
        "200":
          description: List
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /me/beacons:
    get:
      tags: [me]
      summary: My beacons
      responses:
        "200":
          description: List
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BeaconList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /me/api-tokens:
    get:
      tags: [me]
      summary: List my API tokens
      responses:
        "200":
          description: List
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenList" }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /me/api-tokens/{id}:
    delete:
      tags: [me]
      summary: Revoke an API token
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Revoked record
          content:
            application/json:
              schema: { $ref: "#/components/schemas/TokenResponse" }
        "404": { $ref: "#/components/responses/NotFound" }

  /me/beacon-monitors:
    get:
      tags: [monitors]
      summary: List my beacon monitors
      responses:
        "200":
          description: List
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BeaconMonitorList" }
    post:
      tags: [monitors]
      summary: Create a beacon monitor
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BeaconMonitorCreate" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BeaconMonitorResponse" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /me/beacon-monitors/{id}:
    delete:
      tags: [monitors]
      summary: Delete a beacon monitor
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "204":
          description: No content
        "404": { $ref: "#/components/responses/NotFound" }

  /beacon-monitor/measurements:
    post:
      tags: [monitors]
      summary: Upload a beacon-signal-level measurement
      description: |
        Ingest endpoint for the `propmonitor` client. The Bearer token here
        is the `BeaconMonitor` token (issued once by
        `POST /me/beacon-monitors`), not a user API token. One POST per
        integration window per monitor; payloads are never batched.

        `signal_peak_dbfs`, `signal_avg_dbfs`, and the `snr_*` fields are
        computed only over FFT frames whose in-band power exceeded
        `noise_floor + 3 dB`. `signal_active_fraction` reports the duty
        cycle of those frames over the window.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BeaconMeasurementCreate" }
      responses:
        "204":
          description: Accepted and recorded. The monitor's `last_seen_at` is updated.
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: The supplied `beacon_id` is unknown, unapproved, or off-the-air.
        "409":
          description: A measurement for this monitor + beacon + measured_at already exists.
        "422": { $ref: "#/components/responses/ValidationFailed" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /contacts:
    get:
      tags: [contacts]
      summary: List public QSOs
      security: []
      parameters:
        - in: query
          name: page
          schema: { type: integer, minimum: 1, default: 1 }
        - in: query
          name: per_page
          schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
        - in: query
          name: search
          schema: { type: string }
      responses:
        "200":
          description: Paginated list
          content:
            application/json:
              schema: { $ref: "#/components/schemas/PaginatedContactList" }
    post:
      tags: [contacts]
      summary: Submit a QSO
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/ContactCreate" }
      responses:
        "201":
          description: Created
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "409": { $ref: "#/components/responses/Conflict" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /contacts/{id}:
    get:
      tags: [contacts]
      summary: Show a single QSO
      security: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: QSO
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ContactResponse" }
        "404": { $ref: "#/components/responses/NotFound" }

  /beacons:
    get:
      tags: [beacons]
      summary: List approved beacons
      security: []
      responses:
        "200":
          description: List
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BeaconList" }
    post:
      tags: [beacons]
      summary: Submit a beacon
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/BeaconCreate" }
      responses:
        "201":
          description: Created (pending approval)
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BeaconResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "422": { $ref: "#/components/responses/ValidationFailed" }

  /beacons/{id}:
    get:
      tags: [beacons]
      summary: Show a single beacon
      security: []
      parameters:
        - in: path
          name: id
          required: true
          schema: { type: string, format: uuid }
      responses:
        "200":
          description: Beacon
          content:
            application/json:
              schema: { $ref: "#/components/schemas/BeaconResponse" }
        "404": { $ref: "#/components/responses/NotFound" }

  /profiles/{callsign}:
    get:
      tags: [profiles]
      summary: Public per-callsign profile
      security: []
      parameters:
        - in: path
          name: callsign
          required: true
          schema: { type: string }
      responses:
        "200":
          description: Profile + contacts + approved beacons
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ProfileResponse" }
        "404": { $ref: "#/components/responses/NotFound" }

  /scores/bands:
    get:
      tags: [scores]
      summary: Bands with propagation scores
      security: []
      responses:
        "200":
          description: List of bands
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        mhz: { type: integer }
                        label: { type: string }
                        humidity_effect:
                          type: string
                          enum: [beneficial, harmful, neutral]

  /scores:
    get:
      tags: [scores]
      summary: Score + factor breakdown at a grid point
      security: []
      parameters:
        - in: query
          name: band
          required: true
          schema: { type: integer }
        - in: query
          name: lat
          required: true
          schema: { type: number, format: double }
        - in: query
          name: lon
          required: true
          schema: { type: number, format: double }
        - in: query
          name: valid_time
          schema: { type: string, format: date-time }
      responses:
        "200":
          description: Score
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ScoreResponse" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "404": { $ref: "#/components/responses/NotFound" }

  /forecast:
    get:
      tags: [scores]
      summary: 18-hour score timeline at a grid point
      security: []
      parameters:
        - in: query
          name: band
          required: true
          schema: { type: integer }
        - in: query
          name: lat
          required: true
          schema: { type: number, format: double }
        - in: query
          name: lon
          required: true
          schema: { type: number, format: double }
      responses:
        "200":
          description: Timeline
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        valid_time: { type: string, format: date-time }
                        score: { type: integer }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: opaque

  responses:
    BadRequest:
      description: Bad request
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Unauthorized:
      description: Authentication required
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    NotFound:
      description: Resource not found
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    Conflict:
      description: Duplicate
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    ValidationFailed:
      description: Validation failed
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }
    RateLimited:
      description: Rate limit exceeded
      headers:
        RateLimit-Limit: { schema: { type: integer } }
        RateLimit-Remaining: { schema: { type: integer } }
        RateLimit-Reset: { schema: { type: integer } }
        Retry-After: { schema: { type: integer } }
      content:
        application/problem+json:
          schema: { $ref: "#/components/schemas/Problem" }

  schemas:
    Problem:
      type: object
      properties:
        type: { type: string, default: "about:blank" }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        errors:
          type: object
          additionalProperties:
            type: array
            items: { type: string }

    AuthRequest:
      type: object
      required: [email, password, name]
      properties:
        email: { type: string, format: email }
        password: { type: string }
        name: { type: string, maxLength: 100 }
        expires_at: { type: string, format: date-time, nullable: true }

    AuthResponse:
      type: object
      properties:
        token: { type: string }
        data: { $ref: "#/components/schemas/Token" }

    Token:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        inserted_at: { type: string, format: date-time }
        last_used_at: { type: string, format: date-time, nullable: true }
        expires_at: { type: string, format: date-time, nullable: true }
        revoked_at: { type: string, format: date-time, nullable: true }

    TokenList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Token" }

    TokenResponse:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Token" }

    Me:
      type: object
      properties:
        id: { type: string, format: uuid }
        callsign: { type: string }
        name: { type: string }
        email: { type: string, format: email }
        is_admin: { type: boolean }
        confirmed_at: { type: string, format: date-time, nullable: true }
        home_grid: { type: string, nullable: true }
        home_lat: { type: number, format: double, nullable: true }
        home_lon: { type: number, format: double, nullable: true }
        home_elevation_m: { type: integer, nullable: true }

    MeResponse:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Me" }

    HomeQthRequest:
      type: object
      properties:
        home_grid: { type: string }
        home_lat: { type: number, format: double }
        home_lon: { type: number, format: double }
        home_elevation_m: { type: integer }

    Contact:
      type: object
      properties:
        id: { type: string, format: uuid }
        station1: { type: string }
        station2: { type: string }
        qso_timestamp: { type: string, format: date-time }
        grid1: { type: string }
        grid2: { type: string }
        pos1:
          type: object
          properties:
            lat: { type: number }
            lon: { type: number }
        pos2:
          type: object
          properties:
            lat: { type: number }
            lon: { type: number }
        mode: { type: string }
        band_mhz: { type: string }
        distance_km: { type: string, nullable: true }
        private: { type: boolean }
        user_declared_prop_mode: { type: string, nullable: true }
        propagation_mechanism: { type: string, nullable: true }
        propagation_mechanism_confidence:
          type: string
          nullable: true
          enum: [high, medium, low]
        notes: { type: string, nullable: true }
        "mine?": { type: boolean }

    ContactCreate:
      type: object
      required: [station1, station2, qso_timestamp, band, grid1, grid2]
      properties:
        station1: { type: string }
        station2: { type: string }
        qso_timestamp: { type: string, format: date-time }
        band: { type: string }
        grid1: { type: string }
        grid2: { type: string }
        mode: { type: string, enum: [CW, SSB, FM, FT8, FT4, Q65] }
        user_declared_prop_mode: { type: string }
        height1_ft: { type: integer }
        height2_ft: { type: integer }
        private: { type: boolean }
        notes: { type: string, maxLength: 2000 }

    ContactResponse:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Contact" }

    ContactList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Contact" }

    PaginatedContactList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Contact" }
        meta:
          type: object
          properties:
            page: { type: integer }
            per_page: { type: integer }
            total_entries: { type: integer }
            total_pages: { type: integer }

    Beacon:
      type: object
      properties:
        id: { type: string, format: uuid }
        callsign: { type: string }
        frequency_mhz: { type: number }
        lat: { type: number }
        lon: { type: number }
        grid: { type: string }
        power_mw: { type: number }
        height_ft: { type: integer }
        on_the_air: { type: boolean }
        approved: { type: boolean }
        keying: { type: string }
        bearing: { type: string }
        beamwidth_deg: { type: number, nullable: true }
        notes: { type: string, nullable: true }
        inserted_at: { type: string, format: date-time }

    BeaconCreate:
      type: object
      required: [frequency_mhz, callsign, lat, lon, power_mw, height_ft, keying]
      properties:
        frequency_mhz: { type: number }
        callsign: { type: string }
        grid: { type: string }
        lat: { type: number }
        lon: { type: number }
        power_mw: { type: number }
        height_ft: { type: integer }
        on_the_air: { type: boolean }
        keying: { type: string }
        bearing: { type: string }
        beamwidth_deg: { type: number }
        notes: { type: string }

    BeaconResponse:
      type: object
      properties:
        data: { $ref: "#/components/schemas/Beacon" }

    BeaconList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/Beacon" }

    BeaconMonitor:
      type: object
      properties:
        id: { type: string, format: uuid }
        name: { type: string }
        token: { type: string, nullable: true }
        last_seen_at: { type: string, format: date-time, nullable: true }
        inserted_at: { type: string, format: date-time }

    BeaconMonitorCreate:
      type: object
      required: [name]
      properties:
        name: { type: string, maxLength: 100 }

    BeaconMonitorResponse:
      type: object
      properties:
        data: { $ref: "#/components/schemas/BeaconMonitor" }

    BeaconMonitorList:
      type: object
      properties:
        data:
          type: array
          items: { $ref: "#/components/schemas/BeaconMonitor" }

    BeaconMeasurementCreate:
      type: object
      required:
        - beacon_id
        - frequency_hz
        - measured_at
        - integration_s
        - passband_hz
        - gain_db
        - noise_floor_dbfs
        - signal_peak_dbfs
        - signal_avg_dbfs
        - snr_peak_db
        - snr_avg_db
        - signal_active_fraction
        - propmonitor_version
      properties:
        beacon_id:
          type: string
          format: uuid
          description: The beacon this measurement is for.
        frequency_hz:
          type: integer
          minimum: 1
          description: SDR tuned center frequency in Hz.
        measured_at:
          type: string
          format: date-time
          description: UTC ISO-8601 timestamp at the start of the integration window.
        integration_s:
          type: integer
          minimum: 5
          maximum: 3600
          description: Integration window length in seconds.
        passband_hz:
          type: number
          exclusiveMinimum: 0
          description: Bandwidth of the in-band power window.
        gain_db:
          type: number
          minimum: -10
          maximum: 80
          description: SDR-reported gain at upload time.
        noise_floor_dbfs:
          type: number
          description: Median out-of-passband bin power, dBFS.
        signal_peak_dbfs:
          type: number
          description: Peak in-passband power across detected-on-air frames, dBFS.
        signal_avg_dbfs:
          type: number
          description: Mean in-passband power across detected-on-air frames, dBFS.
        snr_peak_db:
          type: number
          description: signal_peak_dbfs minus noise_floor_dbfs.
        snr_avg_db:
          type: number
          description: signal_avg_dbfs minus noise_floor_dbfs.
        signal_active_fraction:
          type: number
          minimum: 0
          maximum: 1
          description: Fraction of FFT frames where in-band power exceeded noise_floor + 3 dB.
        propmonitor_version:
          type: string
          maxLength: 32
          description: Build version of the reporting client (diagnostic).

    ProfileResponse:
      type: object
      properties:
        user:
          type: object
          properties:
            id: { type: string, format: uuid }
            callsign: { type: string }
            name: { type: string }
            home_grid: { type: string, nullable: true }
            home_lat: { type: number, nullable: true }
            home_lon: { type: number, nullable: true }
        contacts:
          type: array
          items: { $ref: "#/components/schemas/Contact" }
        beacons:
          type: array
          items: { $ref: "#/components/schemas/Beacon" }

    ScoreResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            lat: { type: number }
            lon: { type: number }
            score: { type: integer }
            factors:
              type: object
              additionalProperties: true
            profile_source:
              oneOf:
                - { type: string, enum: [exact, unavailable] }
                - { type: array }
            valid_time: { type: string, format: date-time }
