openapi: 3.1.0
info:
  title: channel-surf-tv API
  version: 1.0.0
  description: |
    Read-only JSON API served by the `channel-surf-tv` Cloudflare Worker in front of
    the D1 database (`channel-surf-tv-spots`). All endpoints are `GET`; any other
    method returns `405 METHOD_NOT_ALLOWED`. Unknown `/api/*` paths return
    `404 NOT_FOUND`. Errors share the envelope `{error, code}`. Successful responses
    are cacheable (`Cache-Control: public, max-age=300, stale-while-revalidate=600`)
    and CORS-enabled for allow-listed origins.

    This spec is the source of truth for the API surface: `npm run api:gen` converts
    it to `src/worker/openapi.json` (served live at `/api/openapi.json`) and renders
    `docs/api/API.md`. `npm run health` diffs worker routes against these paths and
    flags drift.
servers:
  - url: https://channel-surf-tv.jgpowers52.workers.dev
    description: Production worker
  - url: http://localhost:8787
    description: Local wrangler dev
tags:
  - name: spots
    description: Surf spot catalog (list, map, facets, detail)
  - name: surfers
    description: Surfer catalog (list, facets, detail)
  - name: quotes
    description: Surf culture quotes wall and Quote of the Day
  - name: clips
    description: Video clips served from R2
  - name: instagram
    description: Curated Instagram feed and account scoring
  - name: meta
    description: API self-description (spec + docs)

paths:
  /api/spots:
    get:
      tags: [spots]
      operationId: listSpots
      summary: Paginated, filterable surf spot list
      description: >
        Full-text search via `q` uses SQLite FTS5 with bm25 ranking; without `q`,
        ordering is tier-weighted (iconic → regional → other) then alphabetical.
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Q'
        - $ref: '#/components/parameters/Country'
        - name: region
          in: query
          schema: { type: string }
        - name: wave_type
          in: query
          schema: { type: string }
          description: e.g. reef, beach, point
        - name: skill_level
          in: query
          schema: { type: string }
          description: e.g. beginner, intermediate, expert
        - name: tag
          in: query
          schema: { type: string }
          description: Tag slug
        - $ref: '#/components/parameters/Tier'
      responses:
        '200':
          description: Page of spots plus total match count
          content:
            application/json:
              schema:
                type: object
                required: [spots, total, limit, offset]
                properties:
                  spots:
                    type: array
                    items: { $ref: '#/components/schemas/Spot' }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }

  /api/spots/map:
    get:
      tags: [spots]
      operationId: mapSpots
      summary: Lightweight spot markers for the Leaflet map
      description: Unpaginated; optionally constrained to a bounding box. Same filters as the list endpoint.
      parameters:
        - name: bbox
          in: query
          schema: { type: string }
          description: 'Bounding box as `min_lat,min_lng,max_lat,max_lng`'
        - $ref: '#/components/parameters/Q'
        - $ref: '#/components/parameters/Country'
        - name: region
          in: query
          schema: { type: string }
        - name: wave_type
          in: query
          schema: { type: string }
        - name: skill_level
          in: query
          schema: { type: string }
        - name: tag
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/Tier'
      responses:
        '200':
          description: Marker payload
          content:
            application/json:
              schema:
                type: object
                required: [spots]
                properties:
                  spots:
                    type: array
                    items:
                      type: object
                      properties:
                        slug: { type: string }
                        name: { type: string }
                        lat: { type: number }
                        lng: { type: number }
                        summary: { type: [string, 'null'] }
                        hero_image: { type: [string, 'null'] }
                        hero_qa_tier: { type: [string, 'null'] }
        '400': { $ref: '#/components/responses/BadRequest' }

  /api/spots/meta:
    get:
      tags: [spots]
      operationId: spotsMeta
      summary: Facet counts for the spot filter sidebar
      parameters:
        - $ref: '#/components/parameters/Q'
        - $ref: '#/components/parameters/Country'
        - name: region
          in: query
          schema: { type: string }
        - name: wave_type
          in: query
          schema: { type: string }
        - name: skill_level
          in: query
          schema: { type: string }
        - name: tag
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/Tier'
      responses:
        '200':
          description: Filtered total plus facet value/count pairs
          content:
            application/json:
              schema:
                type: object
                required: [filtered_total, facets]
                properties:
                  filtered_total: { type: integer }
                  facets:
                    type: object
                    properties:
                      countries: { $ref: '#/components/schemas/FacetList' }
                      regions: { $ref: '#/components/schemas/FacetList' }
                      waveTypes: { $ref: '#/components/schemas/FacetList' }
                      skillLevels: { $ref: '#/components/schemas/FacetList' }
                      tags: { $ref: '#/components/schemas/FacetList' }
                      tiers: { $ref: '#/components/schemas/FacetList' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /api/spots/{slug}:
    get:
      tags: [spots]
      operationId: getSpot
      summary: Spot detail with images, tags, and linked surfers
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Spot detail payload
          content:
            application/json:
              schema:
                type: object
                required: [spot, images, tags, surfers]
                properties:
                  spot: { $ref: '#/components/schemas/Spot' }
                  images:
                    type: array
                    items: { $ref: '#/components/schemas/Image' }
                  tags:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: integer }
                        name: { type: string }
                  surfers:
                    type: array
                    items:
                      type: object
                      properties:
                        slug: { type: string }
                        name: { type: string }
                        relation: { type: [string, 'null'] }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/surfers:
    get:
      tags: [surfers]
      operationId: listSurfers
      summary: Paginated, filterable surfer list
      parameters:
        - $ref: '#/components/parameters/Limit'
        - $ref: '#/components/parameters/Offset'
        - $ref: '#/components/parameters/Q'
        - $ref: '#/components/parameters/Country'
        - name: discipline
          in: query
          schema: { type: string }
        - name: era
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/Tier'
      responses:
        '200':
          description: Page of surfers plus total match count
          content:
            application/json:
              schema:
                type: object
                required: [surfers, total, limit, offset]
                properties:
                  surfers:
                    type: array
                    items: { $ref: '#/components/schemas/Surfer' }
                  total: { type: integer }
                  limit: { type: integer }
                  offset: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }

  /api/surfers/meta:
    get:
      tags: [surfers]
      operationId: surfersMeta
      summary: Facet counts for the surfer filter sidebar
      parameters:
        - $ref: '#/components/parameters/Q'
        - $ref: '#/components/parameters/Country'
        - name: discipline
          in: query
          schema: { type: string }
        - name: era
          in: query
          schema: { type: string }
        - $ref: '#/components/parameters/Tier'
      responses:
        '200':
          description: Filtered total plus facet value/count pairs
          content:
            application/json:
              schema:
                type: object
                required: [filtered_total, facets]
                properties:
                  filtered_total: { type: integer }
                  facets:
                    type: object
                    properties:
                      countries: { $ref: '#/components/schemas/FacetList' }
                      disciplines: { $ref: '#/components/schemas/FacetList' }
                      eras: { $ref: '#/components/schemas/FacetList' }
                      tiers: { $ref: '#/components/schemas/FacetList' }
        '400': { $ref: '#/components/responses/BadRequest' }

  /api/surfers/{slug}:
    get:
      tags: [surfers]
      operationId: getSurfer
      summary: Surfer detail with images and linked spots
      parameters:
        - $ref: '#/components/parameters/Slug'
      responses:
        '200':
          description: Surfer detail payload
          content:
            application/json:
              schema:
                type: object
                required: [surfer, images, spots]
                properties:
                  surfer: { $ref: '#/components/schemas/Surfer' }
                  images:
                    type: array
                    items: { $ref: '#/components/schemas/Image' }
                  spots:
                    type: array
                    items:
                      type: object
                      properties:
                        slug: { type: string }
                        name: { type: string }
                        region: { type: [string, 'null'] }
                        country_code: { type: [string, 'null'] }
                        relation: { type: [string, 'null'] }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/quotes:
    get:
      tags: [quotes]
      operationId: listQuotes
      summary: Quotes wall collection
      description: Full collection, optionally filtered; ordered by id.
      parameters:
        - name: category
          in: query
          schema: { type: string }
        - name: surfer
          in: query
          schema: { type: string }
          description: Surfer slug
      responses:
        '200':
          description: Quotes plus count
          content:
            application/json:
              schema:
                type: object
                required: [quotes, total]
                properties:
                  quotes:
                    type: array
                    items: { $ref: '#/components/schemas/Quote' }
                  total: { type: integer }

  /api/quotes/random:
    get:
      tags: [quotes]
      operationId: quoteOfDay
      summary: Quote of the Day
      description: >
        Deterministic day-indexed pick from the featured pool (same quote for
        everyone on a given UTC day). 404 if the featured pool is empty.
      responses:
        '200':
          description: The day's quote
          content:
            application/json:
              schema:
                type: object
                required: [quote]
                properties:
                  quote: { $ref: '#/components/schemas/Quote' }
        '404': { $ref: '#/components/responses/NotFound' }

  /api/clips:
    get:
      tags: [clips]
      operationId: listClips
      summary: Video clip inventory
      description: Clips ordered featured-first; `src` is a public R2 URL.
      responses:
        '200':
          description: Clip list
          content:
            application/json:
              schema:
                type: object
                required: [clips]
                properties:
                  clips:
                    type: array
                    items:
                      type: object
                      required: [slug, title, src, uploadDate, featured]
                      properties:
                        slug: { type: string }
                        title: { type: string }
                        src: { type: string, format: uri }
                        uploadDate: { type: string }
                        featured: { type: boolean }

  /api/events:
    get:
      tags: [events]
      operationId: listEvents
      summary: Surf event calendar with derived status
      description: >
        Season calendar of surf competitions (WSL CT, ISA, big-wave windows).
        Status (upcoming/live/window-open/completed) is derived from each
        event's date window at request time, never stored.
      parameters:
        - name: org
          in: query
          schema: { type: string }
          description: Filter by organization slug (e.g. wsl, isa).
        - name: tour
          in: query
          schema: { type: string }
          description: Filter by tour/series (e.g. ct, isa-wsg, big-wave).
        - name: year
          in: query
          schema: { type: string }
          description: Filter by start-date year (e.g. 2026).
        - name: status
          in: query
          schema: { type: string, enum: [upcoming, live, window-open, completed] }
          description: Filter by derived status.
      responses:
        '200':
          description: Events ordered by start date
          content:
            application/json:
              schema:
                type: object
                required: [events, total]
                properties:
                  total: { type: integer }
                  events:
                    type: array
                    items:
                      type: object
                      properties:
                        slug: { type: string }
                        name: { type: string }
                        org: { type: string }
                        orgName: { type: [string, 'null'] }
                        tour: { type: [string, 'null'] }
                        startDate: { type: string }
                        endDate: { type: string }
                        windowType: { type: string, enum: [scheduled, holding-period] }
                        location: { type: string }
                        country: { type: [string, 'null'] }
                        spotSlug: { type: [string, 'null'] }
                        url: { type: [string, 'null'] }
                        sourceUrl: { type: [string, 'null'] }
                        summary: { type: [string, 'null'] }
                        status: { type: string, enum: [upcoming, live, window-open, completed] }
                        results:
                          type: [object, 'null']
                          description: Podium for completed events (curated from the results importer); null when no results are recorded.
                          properties:
                            men:
                              type: array
                              items:
                                type: object
                                properties:
                                  place: { type: integer }
                                  name: { type: string }
                                  surferSlug: { type: [string, 'null'] }
                            women:
                              type: array
                              items:
                                type: object
                                properties:
                                  place: { type: integer }
                                  name: { type: string }
                                  surferSlug: { type: [string, 'null'] }

  /api/events/live-media:
    get:
      tags: [events]
      operationId: liveEventMedia
      summary: Recent Instagram posts from the live event's organizer
      description: >
        While an event is live, recent media from the organizing account via
        Business Discovery (1h D1 cache). Empty payload when no event is live,
        the organization has no Instagram handle, or Instagram is unconfigured.
      responses:
        '200':
          description: Live event media rail payload
          content:
            application/json:
              schema:
                type: object
                required: [posts]
                properties:
                  eventSlug: { type: [string, 'null'] }
                  eventName: { type: [string, 'null'] }
                  username: { type: [string, 'null'] }
                  fetchedAt: { type: string }
                  source: { type: string, enum: [live, cache] }
                  posts:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        permalink: { type: string }
                        mediaType: { type: string }
                        timestamp: { type: string }
                        caption: { type: string }
                        mediaUrl: { type: string }
                        thumbnailUrl: { type: string }
                        username: { type: string }

  /api/instagram/feed:
    get:
      tags: [instagram]
      operationId: instagramFeed
      summary: Curated Instagram feed
      description: >
        Served from the D1 cache (refreshed by a 6-hourly cron); lazily refreshed
        on cache miss. Returns an empty post list when Instagram credentials are
        not configured; 503 when a live refresh fails with no cache to fall back on.
      responses:
        '200':
          description: Feed payload
          content:
            application/json:
              schema:
                type: object
                required: [profileUrl, username, fetchedAt, source, posts]
                properties:
                  profileUrl: { type: string, format: uri }
                  username: { type: string }
                  fetchedAt: { type: string, format: date-time }
                  source: { type: string, enum: [cache, live] }
                  posts:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        caption: { type: [string, 'null'] }
                        mediaType: { type: string }
                        mediaUrl: { type: string, format: uri }
                        permalink: { type: string, format: uri }
                        timestamp: { type: string }
                        username: { type: string }
        '503': { $ref: '#/components/responses/Unavailable' }

  /api/instagram/accounts:
    get:
      tags: [instagram]
      operationId: instagramAccounts
      summary: Curated account scores with follower history
      description: >
        Latest score snapshot per curated account, ranked by score then followers,
        each with a recent follower-count history series. Empty list when
        Instagram credentials are not configured.
      responses:
        '200':
          description: Scored accounts
          content:
            application/json:
              schema:
                type: object
                required: [accounts]
                properties:
                  accounts:
                    type: array
                    items:
                      type: object
                      properties:
                        handle: { type: string }
                        ok: { type: boolean }
                        followers: { type: [integer, 'null'] }
                        lastPostAt: { type: [string, 'null'] }
                        avgEngagement: { type: [number, 'null'] }
                        engagementRate: { type: [number, 'null'] }
                        active: { type: boolean }
                        score: { type: [number, 'null'] }
                        scoredAt: { type: string }
                        history:
                          type: array
                          items:
                            type: object
                            properties:
                              scoredAt: { type: string }
                              followers: { type: integer }

  /api/openapi.json:
    get:
      tags: [meta]
      operationId: getOpenApiSpec
      summary: This OpenAPI specification as JSON
      description: >
        The spec bundled into the deployed worker at build time, so it always
        matches the running version.
      responses:
        '200':
          description: OpenAPI 3.1 document
          content:
            application/json:
              schema:
                type: object

  /api/docs:
    get:
      tags: [meta]
      operationId: getApiDocs
      summary: Human-readable API reference
      description: Dependency-free HTML rendered from the bundled spec at request time.
      responses:
        '200':
          description: HTML documentation page
          content:
            text/html:
              schema:
                type: string

components:
  parameters:
    Limit:
      name: limit
      in: query
      schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
    Offset:
      name: offset
      in: query
      schema: { type: integer, minimum: 0, default: 0 }
    Q:
      name: q
      in: query
      schema: { type: string }
      description: Full-text search (FTS5)
    Country:
      name: country
      in: query
      schema: { type: string }
      description: ISO 3166-1 alpha-2 country code
    Tier:
      name: tier
      in: query
      schema: { type: string }
      description: Curation tier, e.g. iconic, regional
    Slug:
      name: slug
      in: path
      required: true
      schema: { type: string }

  responses:
    BadRequest:
      description: Invalid query parameter
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
    Unavailable:
      description: Upstream dependency unavailable
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }

  schemas:
    Error:
      type: object
      required: [error, code]
      properties:
        error: { type: string }
        code: { type: string }

    FacetList:
      type: array
      items:
        type: object
        required: [value, count]
        properties:
          value: { type: string }
          count: { type: integer }

    Spot:
      type: object
      properties:
        id: { type: integer }
        slug: { type: string }
        name: { type: string }
        country_code: { type: [string, 'null'] }
        region: { type: [string, 'null'] }
        latitude: { type: [number, 'null'] }
        longitude: { type: [number, 'null'] }
        summary: { type: [string, 'null'] }
        wave_type: { type: [string, 'null'] }
        skill_level: { type: [string, 'null'] }
        best_months: { type: [string, 'null'] }
        tier: { type: [string, 'null'] }
        created_at: { type: string }
        updated_at: { type: string }
        hero_image: { type: [string, 'null'] }
        hero_qa_tier: { type: [string, 'null'] }

    Surfer:
      type: object
      properties:
        id: { type: integer }
        slug: { type: string }
        name: { type: string }
        country_code: { type: [string, 'null'] }
        region: { type: [string, 'null'] }
        birth_year: { type: [integer, 'null'] }
        death_year: { type: [integer, 'null'] }
        stance: { type: [string, 'null'] }
        discipline: { type: [string, 'null'] }
        era: { type: [string, 'null'] }
        summary: { type: [string, 'null'] }
        tier: { type: [string, 'null'] }
        hero_image: { type: [string, 'null'] }

    Quote:
      type: object
      required: [slug, text]
      properties:
        slug: { type: string }
        text: { type: string }
        author: { type: [string, 'null'] }
        category: { type: [string, 'null'] }
        sourceUrl: { type: [string, 'null'], format: uri }
        credit: { type: [string, 'null'] }
        surferSlug: { type: [string, 'null'] }

    Image:
      type: object
      properties:
        id: { type: integer }
        url: { type: string, format: uri }
        alt_text: { type: [string, 'null'] }
        sort_order: { type: integer }
        credit: { type: [string, 'null'] }
