openapi: 3.1.0
info:
  title: Shared Events Library (SEL) API
  version: 1.0.0
  description: |
    RESTful API for the Shared Events Library, a federated events commons 
    enabling discovery, submission, and management of cultural events.
    
    ## Getting an API Key
    
    **Public read access:** No authentication required for reading events, places, or organizations.
    
    **Authenticated write access:** To submit or manage events, you need an API key.
    
    ### Two Onboarding Paths
    
    #### Option 1: Self-Service via GitHub OAuth (Instant)
    
    1. Visit `/dev/login` and click "Sign in with GitHub"
    2. Authorize the app on GitHub
    3. Your developer account is automatically created
    4. Navigate to `/dev/api-keys` to create your first API key
    
    #### Option 2: Email Invitation (Email/Password)
    
    1. **Request an invitation:** Email [info@togather.foundation](mailto:info@togather.foundation) with:
       - Your name
       - Email address
       - Intended use case
    
    2. **Accept invitation:**
       - Check your email for the invitation link (valid for 7 days)
       - Set your password and display name
       - You'll be logged in to the developer dashboard at `/dev/login`
    
    3. **Create an API key:**
       - Navigate to `/dev/api-keys`
       - Click "Create New Key"
       - Enter a descriptive name
       - Copy the key immediately (shown only once)
       - Store securely (environment variable or secret manager)
    
    ### Using Your API Key
    
    **Via Web Interface:**
    1. Navigate to `/dev/api-keys`
    2. Click "Create New Key"
    3. Enter a descriptive name
    4. Copy the key immediately (shown only once)
    5. Store securely (environment variable or secret manager)
    
    **Via API:**
    ```bash
    # First, get your JWT token (if using email/password)
    curl -X POST https://toronto.togather.foundation/api/v1/dev/login \
         -H "Content-Type: application/json" \
         -d '{"email": "your-email@example.com", "password": "your-password"}'
    
    # Then create a key
    curl -X POST https://toronto.togather.foundation/api/v1/dev/api-keys \
         -H "Authorization: Bearer $YOUR_JWT_TOKEN" \
         -H "Content-Type: application/json" \
         -d '{"name": "My Event Scraper"}'
    ```
    
    ### Making Authenticated Requests
    
    **Reading events (no auth):**
    ```bash
    curl https://toronto.togather.foundation/api/v1/events
    ```
    
    **Submitting events (with API key):**
    ```bash
    curl -X POST https://toronto.togather.foundation/api/v1/events \
         -H "Authorization: Bearer your_api_key_here" \
         -H "Content-Type: application/ld+json" \
         -d @event.json
    ```
    
    **For complete guidance**, see:
    - [Developer Quick Start](https://github.com/Togather-Foundation/server/blob/main/docs/integration/DEVELOPER_QUICKSTART.md) (4-step onboarding)
    - [Authentication Guide](https://github.com/Togather-Foundation/server/blob/main/docs/integration/AUTHENTICATION.md) (comprehensive auth docs)
    - [API Guide](https://github.com/Togather-Foundation/server/blob/main/docs/integration/API_GUIDE.md) (endpoint reference)
    
    ## Authentication
    - **Public**: No authentication required for read endpoints
    - **Agents**: API key in `Authorization: Bearer <key>` header
    - **Admins**: JWT token in `Authorization: Bearer <token>` header
    - **Developers**: JWT token in `dev_auth_token` cookie or `Authorization: Bearer <token>` header

    ## Rate Limits
    All public (unauthenticated) read endpoints share a single rate limit tier, configurable
    via `RATE_LIMIT_PUBLIC` (default 60 requests per minute per IP). Individual endpoints
    may have tighter per-endpoint limits:
    - `POST /api/v1/dev/login`: configurable via `RATE_LIMIT_LOGIN`, default 5 attempts per 15-minute window per IP
    - `POST /scraper/submissions`: configurable via `RATE_LIMIT_SUBMISSIONS_PER_IP_PER_24H`, default 20 URLs per 24 hours per IP
    
    ## Content Negotiation
    API endpoints return JSON/JSON-LD via `Accept` header:
    - `application/ld+json` - JSON-LD (default)
    - `application/json` - JSON-LD (alias)
    
    ## Pagination
    List endpoints use cursor-based pagination with `after` and `limit` parameters.
    Default limit is 50, maximum is 200.
    
    ### Cursor Format
    Cursors are opaque base64url-encoded strings (RFC 4648, URL-safe, no padding).
    - **Event cursors**: Encode timestamp + ULID for stable ordering
    - **Change feed cursors**: Encode sequence number for federation sync
    
    **Important**: Treat cursors as opaque strings. Use the `next_cursor` value from
    API responses as-is. Do not parse, decode, or modify cursor values.
    
    Example pagination:
    ```
    GET /api/v1/events?limit=50
    -> returns { items: [...], next_cursor: "MTcwNjg4NjAwMD..." }
    
    GET /api/v1/events?limit=50&after=MTcwNjg4NjAwMD...
    -> returns next page
    ```
  contact:
    name: SEL Team
    url: https://togather.foundation
    email: hello@togather.foundation
  license:
    name: CC0 1.0
    url: https://creativecommons.org/publicdomain/zero/1.0/

servers:
  - url: https://{node}/api/v1
    description: SEL Node API
    variables:
      node:
        default: toronto.togather.foundation
        description: SEL node domain

tags:
  - name: Admin
    description: Administrative operations (requires admin auth)
  - name: Developer
    description: Developer self-service API key management
  - name: Events
    description: Event discovery and submission
  - name: Feeds
    description: Change feeds for federation
  - name: Health
    description: Health check endpoints
  - name: OAuth
    description: GitHub OAuth authentication
  - name: Organizations
    description: Organizer information
  - name: Places
    description: Venue information

paths:
  /events:
    get:
      operationId: listEvents
      summary: List events
      description: Query events with optional filters. Returns paginated JSON-LD.
      tags: [Events]
      security: []
      parameters:
        - $ref: '#/components/parameters/startDate'
        - $ref: '#/components/parameters/endDate'
        - $ref: '#/components/parameters/city'
        - $ref: '#/components/parameters/venueId'
        - $ref: '#/components/parameters/organizerId'
        - $ref: '#/components/parameters/lifecycleState'
        - $ref: '#/components/parameters/eventDomain'
        - $ref: '#/components/parameters/query'
        - $ref: '#/components/parameters/keywords'
        - $ref: '#/components/parameters/after'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Paginated list of events
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/EventListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'

    post:
      operationId: createEvent
      summary: Submit a new event
      description: |
        Submit a new event to the library. Requires agent or admin authentication.
        Events are auto-published immediately and appear in admin review queue
        if flagged or low-confidence.
      tags: [Events]
      security:
        - apiKey: []
        - bearerAuth: []
      requestBody:
        description: Event data in JSON-LD or JSON format
        required: true
        content:
          application/ld+json:
            schema:
              $ref: '#/components/schemas/EventInput'
          application/json:
            schema:
              $ref: '#/components/schemas/EventInput'
      responses:
        '201':
          description: Event created successfully
          headers:
            Location:
              description: URI of the created event
              schema:
                type: string
                format: uri
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/Event'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Duplicate event (idempotent - returns existing event)
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/Event'

  /events/{id}:
    get:
      operationId: getEvent
      summary: Get event by ID
      description: Retrieve a single event by its ULID. Supports content negotiation.
      tags: [Events]
      security: []
      parameters:
        - $ref: '#/components/parameters/eventId'
      responses:
        '200':
          description: Event details
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/Event'
        '404':
          $ref: '#/components/responses/NotFound'

  /events/batch:
    post:
      operationId: createEventBatch
      summary: Submit multiple events in a batch
      description: |
        Submit multiple events for background processing. Returns immediately with a batch ID
        and job status URL. Use the batch status endpoint to check processing results.
        
        Maximum batch size: 100 events. Each event in the batch is processed independently,
        with individual success/failure tracking.
      tags: [Events]
      security:
        - apiKey: []
        - bearerAuth: []
      requestBody:
        description: Array of events for batch processing (max 100 events)
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - events
              properties:
                events:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    $ref: '#/components/schemas/EventInput'
      responses:
        '202':
          description: Batch accepted for processing
          content:
            application/json:
              schema:
                type: object
                properties:
                  '@context':
                    type: string
                  '@type':
                    type: string
                    enum: [BatchSubmission]
                  batch_id:
                    type: string
                    description: Unique identifier for this batch submission
                  job_id:
                    type: integer
                    description: River job queue ID
                  status:
                    type: string
                    enum: [processing]
                  status_url:
                    type: string
                    format: uri
                    description: URL to check batch processing status
                  submitted:
                    type: integer
                    description: Number of events in the batch
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'

  /batch-status/{id}:
    get:
      operationId: getBatchStatus
      summary: Get batch processing status
      description: |
        Retrieve the status and results of a batch event submission. Returns 404 if
        the batch is still processing or doesn't exist.
      tags: [Events]
      security: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
          description: Batch ID returned from batch submission
      responses:
        '200':
          description: Batch processing completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  '@context':
                    type: string
                  '@type':
                    type: string
                    enum: [BatchSubmissionResult]
                  batch_id:
                    type: string
                  status:
                    type: string
                    enum: [completed]
                  completed_at:
                    type: string
                    format: date-time
                  total:
                    type: integer
                    description: Total number of events processed
                  created:
                    type: integer
                    description: Number of events successfully created
                  failed:
                    type: integer
                    description: Number of events that failed validation/processing
                  duplicates:
                    type: integer
                    description: Number of duplicate events detected
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        index:
                          type: integer
                          description: Index of event in original batch array
                        status:
                          type: string
                          enum: [created, failed, duplicate]
                        event_id:
                          type: string
                          description: ULID of created/duplicate event (if successful)
                        error:
                          type: string
                          description: Error message (if failed)
        '404':
          description: Batch not found or still processing
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

        '410':
          $ref: '#/components/responses/Gone'

  /places:
    get:
      operationId: listPlaces
      summary: List places
      description: Query places (venues) with optional filters. Returns paginated JSON-LD.
      tags: [Places]
      security: []
      parameters:
        - $ref: '#/components/parameters/city'
        - $ref: '#/components/parameters/query'
        - $ref: '#/components/parameters/after'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Paginated list of places
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/PlaceListResponse'

  /places/{id}:
    get:
      operationId: getPlace
      summary: Get place by ID
      description: Retrieve a single place (venue) by its unique identifier. Returns JSON-LD.
      tags: [Places]
      security: []
      parameters:
        - name: id
          in: path
          required: true
          description: Unique identifier of the place (ULID or URI)
          schema:
            type: string
      responses:
        '200':
          description: Place details
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/Place'
        '404':
          $ref: '#/components/responses/NotFound'

  /organizations:
    get:
      operationId: listOrganizations
      summary: List organizations
      description: Query organizations (event organizers) with optional filters. Returns paginated JSON-LD.
      tags: [Organizations]
      security: []
      parameters:
        - $ref: '#/components/parameters/query'
        - $ref: '#/components/parameters/after'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Paginated list of organizations
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/OrganizationListResponse'

  /organizations/{id}:
    get:
      operationId: getOrganization
      summary: Get organization by ID
      description: Retrieve a single organization by its unique identifier. Returns JSON-LD.
      tags: [Organizations]
      security: []
      parameters:
        - name: id
          in: path
          required: true
          description: Unique identifier of the organization (ULID or URI)
          schema:
            type: string
      responses:
        '200':
          description: Organization details
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/Organization'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/events/pending:
    get:
      operationId: listPendingEvents
      summary: List events awaiting review
      description: Returns events flagged for admin review (low confidence, validation issues, etc.)
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/after'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Paginated list of pending events
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/EventListResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/events/{id}:
    put:
      operationId: updateEvent
      summary: Update an event
      description: Admin endpoint to edit event details. Increments version and logs change.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/eventId'
      requestBody:
        description: Updated event data with version for optimistic locking
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/EventUpdateInput'
      responses:
        '200':
          description: Event updated
          content:
            application/ld+json:
              schema:
                $ref: '#/components/schemas/Event'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Version conflict (optimistic locking)
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

    delete:
      operationId: deleteEvent
      summary: Delete an event
      description: Soft delete - returns 410 Gone with tombstone thereafter
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/eventId'
      responses:
        '204':
          description: Event deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/events/consolidate:
    post:
      operationId: consolidateEvents
      summary: Atomically consolidate multiple events into one canonical event
      description: |
        Consolidate N events into a single canonical event in one atomic operation.
        Either creates a new canonical event (`event`) or promotes an existing one
        (`event_ulid`). All events listed in `retire` are soft-deleted with tombstones.
        Open review queue entries for retired events are dismissed automatically.

        Post-consolidation validation (duplicate detection, quality checks) runs on
        the canonical event; results are returned in `warnings`, `is_duplicate`, and
        `needs_review`. Near-duplicate detection after consolidation uses a similarity
        threshold configurable via DEDUP_NEAR_DUPLICATE_THRESHOLD (default 0.4).

        Exactly one of the following must hold:
        - `event_ulid` only: promote an existing event as canonical with no field changes.
        - `event_ulid` + `event`: promote an existing event as canonical AND apply field
          edits atomically inside the same transaction. Patchable fields: `name`,
          `description`, `url`, `image`, `keywords`, `eventDomain`. Fields ignored on the
          patch path: `startDate`, `endDate`, `doorTime`, `occurrences`, `source`,
          `lifecycle_state`, and scraper-only hints.
        - `event` only: create a new canonical event (full ingest pipeline).

        `retire` is required and must contain at least one ULID. The canonical event's
        ULID must not appear in the `retire` list.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - retire
              properties:
                event:
                  description: >
                    When `event_ulid` is absent: full event payload to create a new
                    canonical event. When `event_ulid` is present: partial patch applied
                    atomically to the promoted canonical event. Patchable fields: name,
                    description, url, image, keywords, eventDomain. Occurrence-derived
                    and ingest-only fields (startDate, endDate, occurrences, source,
                    lifecycle_state) are ignored on the patch path.
                  $ref: '#/components/schemas/EventInput'
                event_ulid:
                  type: string
                  description: >
                    ULID of an existing event to promote as the canonical event.
                    May be combined with `event` to apply field edits atomically.
                retire:
                  type: array
                  items:
                    type: string
                  minItems: 1
                  description: >
                    Event ULIDs to soft-delete with tombstones. All must exist and
                    not already be deleted. Required, minimum 1 entry.
                transfer_occurrences:
                  type: boolean
                  default: false
                  description: >
                    If true, occurrences are copied from each retired event to the
                    canonical event before retirement. Returns 409 if any occurrence
                    would overlap an existing one on the canonical. Default false
                    (Merge Duplicate path — retire only, no occurrence transfer).
      responses:
        '200':
          description: Consolidation succeeded
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConsolidateResponse'
        '400':
          description: |
            Invalid request. Possible reasons:
            - Neither `event` nor `event_ulid` was provided
            - `retire` list is empty or missing
            - Canonical event's ULID appears in `retire`
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          description: Retire target or canonical event not found
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '409':
          description: |
            An occurrence from a retired event overlaps an existing occurrence on
            the canonical event (only when transfer_occurrences is true).
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '422':
          description: |
            Unprocessable request. Possible reasons:
            - One or more retire targets are already deleted
            - The canonical event has been deleted
            - Validation error in the provided event payload
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/events/{id}/occurrences:
    post:
      operationId: createOccurrence
      summary: Add an occurrence to an event
      description: |
        Create a new occurrence on an existing event. The event must not be deleted.
        Returns 409 if the new occurrence overlaps an existing occurrence on the event.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/eventId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OccurrenceInput'
      responses:
        '201':
          description: Occurrence created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OccurrenceResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Occurrence overlaps an existing occurrence
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '410':
          description: Event has been deleted
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '422':
          description: Validation error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/events/{id}/occurrences/{occurrenceId}:
    put:
      operationId: updateOccurrence
      summary: Update an occurrence on an event
      description: |
        Update an existing occurrence. Supports partial updates via nullable fields.
        Returns 409 if the updated occurrence would overlap another occurrence on the event.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/eventId'
        - name: occurrenceId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: UUID of the occurrence to update
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/OccurrenceInput'
      responses:
        '200':
          description: Occurrence updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OccurrenceResponse'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '409':
          description: Occurrence overlaps an existing occurrence
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '410':
          description: Event has been deleted
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '422':
          description: Validation error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

    delete:
      operationId: deleteOccurrence
      summary: Delete an occurrence from an event
      description: |
        Remove a single occurrence from an event. The last occurrence of an event
        cannot be deleted (returns 422); delete the event itself instead.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/eventId'
        - name: occurrenceId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: UUID of the occurrence to delete
      responses:
        '204':
          description: Occurrence deleted
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '410':
          description: Event has been deleted
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '422':
          description: Cannot delete the last occurrence of an event
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/api-keys:
    get:
      operationId: listApiKeys
      summary: List API keys
      description: Retrieve all API keys (without secrets). Requires admin authentication.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      responses:
        '200':
          description: List of API keys (without secrets)
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/ApiKeyInfo'

    post:
      operationId: createApiKey
      summary: Create a new API key
      description: Generate a new API key for agent authentication. Returns the API key secret only once. Requires admin authentication.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      requestBody:
        description: API key name and optional metadata (sourceId, expiresAt)
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                sourceId:
                  type: string
                  format: uuid
                expiresAt:
                  type: string
                  format: date-time
      responses:
        '201':
          description: API key created (secret shown once)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiKeyCreated'

  /feeds/changes:
    get:
      operationId: getChangeFeed
      summary: Get change feed
      description: |
        Ordered stream of changes for federation sync. Returns events in 
        sequence order with action type and snapshot.
      tags: [Feeds]
      security: []
      parameters:
        - name: since
          in: query
          description: Cursor from previous response (e.g., seq_1048576)
          schema:
            type: string
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: Change feed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ChangeFeedResponse'

  /admin/review-queue:
    get:
      operationId: listReviewQueue
      summary: List review queue entries
      description: |
        Returns events awaiting admin review due to validation warnings or normalization changes.
        Supports filtering by status and pagination.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          description: Filter by review status (defaults to 'pending')
          schema:
            type: string
            enum: [pending, approved, rejected, merged]
            default: pending
        - name: cursor
          in: query
          description: Pagination cursor (review queue entry ID)
          schema:
            type: string
        - name: limit
          in: query
          description: Maximum results per page (default 50, max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
      responses:
        '200':
          description: Paginated list of review queue entries
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReviewQueueListResponse'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/review-queue/{id}:
    get:
      operationId: getReviewQueueEntry
      summary: Get review queue entry details
      description: |
        Retrieve detailed information for a specific review queue entry, including
        original payload, normalized payload, detected changes, and validation warnings.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Review queue entry ID
          schema:
            type: integer
            minimum: 1
      responses:
        '200':
          description: Review queue entry details
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReviewQueueDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/review-queue/{id}/approve:
    post:
      operationId: approveReviewQueueEntry
      summary: Approve a review queue entry
      description: |
        Approve an event in the review queue and publish it. The event's lifecycle
        state will be updated to 'published' and it will appear in public listings.

        Set `record_not_duplicates: true` to implement the "Not a Duplicate" action:
        the event is published and the duplicate-warning candidates are recorded as a
        confirmed non-duplicate pair, suppressing re-flagging on future ingest.
        This differs from a plain approve only in the side-effect on the
        `not_duplicates` table; the event transition is identical.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Review queue entry ID
          schema:
            type: integer
            minimum: 1
      requestBody:
        description: Optional review notes for approval
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                notes:
                  type: string
                  description: Optional review notes
                record_not_duplicates:
                  type: boolean
                  description: |
                    When `true`, any `potential_duplicate` warning candidates in this
                    review entry are recorded as confirmed non-duplicates. Future ingest
                    of events that match these pairs will not generate duplicate warnings.
                    Used by the "Not a Duplicate" UI action, which approves/publishes the
                    event while acknowledging that the duplicate flag was incorrect.
                    Matched companion pending reviews are rechecked best-effort after the
                    duplicate warnings are removed: if no issues remain they are
                    auto-approved, otherwise they stay pending with refreshed warnings.
            examples:
              withNotes:
                summary: Approval with notes
                value:
                  notes: "Date corrections look good, publishing"
              withoutNotes:
                summary: Simple approval
                value: {}
              notADuplicate:
                summary: Approve as not-a-duplicate (suppresses future duplicate warnings)
                value:
                  notes: "Different event, same venue"
                  record_not_duplicates: true
      responses:
        '200':
          description: Review approved and event published
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReviewQueueDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          description: Review entry has already been processed
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '410':
          description: Event has been deleted
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/review-queue/{id}/reject:
    post:
      operationId: rejectReviewQueueEntry
      summary: Reject a review queue entry
      description: |
        Reject an event in the review queue and mark it as deleted. The event will
        return HTTP 410 Gone with a tombstone and will not appear in public listings.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Review queue entry ID
          schema:
            type: integer
            minimum: 1
      requestBody:
        description: Rejection reason (required) explaining why the event was rejected
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reason]
              properties:
                reason:
                  type: string
                  description: Required rejection reason
            examples:
              invalidDates:
                summary: Reject due to invalid dates
                value:
                  reason: "Event dates are in the past and cannot be corrected"
              spam:
                summary: Reject spam event
                value:
                  reason: "Spam submission - not a legitimate event"
      responses:
        '200':
          description: Review rejected and event deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReviewQueueDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          description: Review entry has already been processed
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '410':
          description: Event has already been deleted
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/review-queue/{id}/fix:
    post:
      operationId: fixReviewQueueEntry
      summary: Fix event dates and approve
      description: |
        Apply date corrections to an event and approve it for publication.
        This endpoint is intended for fixing date-related issues detected during normalization.
        
        Note: Currently this endpoint only marks the review as approved with correction notes.
        Full date correction implementation is tracked in srv-trg.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Review queue entry ID
          schema:
            type: integer
            minimum: 1
      requestBody:
        description: Date corrections to apply to the event (startDate and/or endDate)
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [corrections]
              properties:
                corrections:
                  type: object
                  description: Date corrections to apply
                  properties:
                    startDate:
                      type: string
                      format: date-time
                      description: Corrected start date
                    endDate:
                      type: string
                      format: date-time
                      description: Corrected end date
                notes:
                  type: string
                  description: Optional notes about the corrections
            examples:
              fixEndDate:
                summary: Fix reversed end date
                value:
                  corrections:
                    endDate: "2026-03-15T22:00:00Z"
                  notes: "Added 24 hours to fix reversed dates"
              fixBothDates:
                summary: Fix both dates
                value:
                  corrections:
                    startDate: "2026-03-15T19:00:00Z"
                    endDate: "2026-03-15T22:00:00Z"
                  notes: "Corrected timezone offset"
      responses:
        '200':
          description: Dates corrected and review approved
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ReviewQueueDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '409':
          description: Review entry has already been processed
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '410':
          description: Event has been deleted
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /admin/login:
    post:
      operationId: login
      summary: Admin login
      description: Authenticate admin user and receive JWT token
      tags: [Admin]
      x-internal: true
      requestBody:
        description: Admin credentials (email and password) for authentication
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
                  format: password
      responses:
        '200':
          description: Login successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                  expiresAt:
                    type: string
                    format: date-time
        '401':
          $ref: '#/components/responses/Unauthorized'

  /admin/developers/invite:
    post:
      operationId: inviteDeveloper
      summary: Invite a developer
      description: Send an invitation to a developer by email. The developer receives an email with a link to accept the invitation.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      requestBody:
        description: Developer invitation details
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
                  description: Developer's email address
                name:
                  type: string
                  description: Optional developer name
                max_keys:
                  type: integer
                  default: 5
                  minimum: 1
                  maximum: 20
                  description: Maximum API keys this developer can create
      responses:
        '201':
          description: Invitation sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  id:
                    type: string
                    format: uuid
                  email:
                    type: string
                  status:
                    type: string
                    enum: [invited]
                  invitation_expires_at:
                    type: string
                    format: date-time
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          description: Email already registered
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/developers:
    get:
      operationId: listDevelopers
      summary: List all developers
      description: Retrieve a list of all developers with their API key count and usage statistics
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          description: Filter by developer status
          schema:
            type: string
            enum: [active, invited, deactivated]
        - $ref: '#/components/parameters/after'
        - $ref: '#/components/parameters/limit'
      responses:
        '200':
          description: List of developers
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/Developer'
                  next_cursor:
                    type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'

  /admin/developers/{id}:
    get:
      operationId: getDeveloper
      summary: Get developer details
      description: Retrieve details for a specific developer including their API keys
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Developer UUID
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Developer details
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Developer'
                  - type: object
                    properties:
                      api_keys:
                        type: array
                        items:
                          $ref: '#/components/schemas/DeveloperAPIKey'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    put:
      operationId: updateDeveloper
      summary: Update developer settings
      description: Update developer's max_keys or is_active status
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Developer UUID
          schema:
            type: string
            format: uuid
      requestBody:
        description: Developer settings to update
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                max_keys:
                  type: integer
                  minimum: 1
                  maximum: 20
                is_active:
                  type: boolean
      responses:
        '200':
          description: Developer updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Developer'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

    delete:
      operationId: deleteDeveloper
      summary: Deactivate developer
      description: Deactivate developer and revoke all their API keys
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: Developer UUID
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: Developer deactivated
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/dev/login:
    post:
      operationId: developerLogin
      summary: Developer login
      description: Authenticate developer with email/password and receive JWT token. Rate limited to 5 attempts per 15-minute window per IP (configurable via `RATE_LIMIT_LOGIN`, default 5).
      tags: [Developer]
      security: []
      requestBody:
        description: Developer credentials
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                password:
                  type: string
                  format: password
      responses:
        '200':
          description: Login successful
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: JWT token for API access
                  expires_at:
                    type: string
                    format: date-time
                  developer:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      email:
                        type: string
                      name:
                        type: string
                      github_username:
                        type: string
        '401':
          $ref: '#/components/responses/Unauthorized'
        '429':
          description: Too many login attempts
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /api/v1/dev/logout:
    post:
      operationId: developerLogout
      summary: Developer logout
      description: Clear dev_auth_token cookie
      tags: [Developer]
      security:
        - devCookie: []
      responses:
        '204':
          description: Logout successful
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/dev/api-keys:
    get:
      operationId: listDeveloperApiKeys
      summary: List own API keys
      description: Retrieve all API keys belonging to the authenticated developer with usage summary
      tags: [Developer]
      security:
        - devCookie: []
        - devJWT: []
      responses:
        '200':
          description: List of API keys
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/DeveloperAPIKey'
                  max_keys:
                    type: integer
                    description: Maximum keys allowed for this developer
                  key_count:
                    type: integer
                    description: Current number of keys
        '401':
          $ref: '#/components/responses/Unauthorized'

    post:
      operationId: createDeveloperApiKey
      summary: Create a new API key
      description: Create a new API key for the authenticated developer. Enforces max_keys limit.
      tags: [Developer]
      security:
        - devCookie: []
        - devJWT: []
      requestBody:
        description: API key details
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  minLength: 1
                  maxLength: 100
                  description: Human-readable name for the key
      responses:
        '201':
          description: API key created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeveloperAPIKeyCreated'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '409':
          description: Max keys exceeded
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /api/v1/dev/api-keys/{id}:
    delete:
      operationId: revokeDeveloperApiKey
      summary: Revoke own API key
      description: Revoke an API key belonging to the authenticated developer
      tags: [Developer]
      security:
        - devCookie: []
        - devJWT: []
      parameters:
        - name: id
          in: path
          required: true
          description: API key UUID
          schema:
            type: string
            format: uuid
      responses:
        '204':
          description: API key revoked
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Key not owned by developer
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/dev/api-keys/{id}/usage:
    get:
      operationId: getDeveloperApiKeyUsage
      summary: Get API key usage statistics
      description: Retrieve usage statistics for a specific API key with daily breakdown
      tags: [Developer]
      security:
        - devCookie: []
        - devJWT: []
      parameters:
        - name: id
          in: path
          required: true
          description: API key UUID
          schema:
            type: string
            format: uuid
        - name: from
          in: query
          description: Start date for usage period (ISO 8601 date)
          schema:
            type: string
            format: date
        - name: to
          in: query
          description: End date for usage period (ISO 8601 date)
          schema:
            type: string
            format: date
      responses:
        '200':
          description: API key usage statistics
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/APIKeyUsage'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          description: Key not owned by developer
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '404':
          $ref: '#/components/responses/NotFound'

  /api/v1/dev/accept-invitation:
    post:
      operationId: acceptDeveloperInvitation
      summary: Accept developer invitation
      description: Accept an invitation by token and set up developer account with password
      tags: [Developer]
      security: []
      requestBody:
        description: Invitation acceptance details
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, name, password]
              properties:
                token:
                  type: string
                  description: Invitation token from email
                name:
                  type: string
                  minLength: 1
                  maxLength: 100
                  description: Developer's full name
                password:
                  type: string
                  format: password
                  minLength: 8
                  description: Password for future logins (configurable via DEVELOPER_PASSWORD_MIN_LENGTH, default 8)
      responses:
        '200':
          description: Invitation accepted, developer account created
          content:
            application/json:
              schema:
                type: object
                properties:
                  token:
                    type: string
                    description: JWT token for immediate access
                  developer:
                    type: object
                    properties:
                      id:
                        type: string
                        format: uuid
                      email:
                        type: string
                      name:
                        type: string
        '400':
          description: Invalid token or weak password
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '410':
          description: Invitation expired or already used
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /auth/github:
    get:
      operationId: githubOAuthStart
      summary: Initiate GitHub OAuth flow
      description: Redirect to GitHub OAuth authorization page with proper state parameter for CSRF protection
      tags: [OAuth]
      security: []
      responses:
        '302':
          description: Redirect to GitHub OAuth
          headers:
            Location:
              description: GitHub OAuth authorization URL
              schema:
                type: string
                format: uri

  /auth/github/callback:
    get:
      operationId: githubOAuthCallback
      summary: GitHub OAuth callback
      description: Handle GitHub OAuth callback, exchange code for token, and create/login developer
      tags: [OAuth]
      security: []
      parameters:
        - name: code
          in: query
          required: true
          description: Authorization code from GitHub
          schema:
            type: string
        - name: state
          in: query
          required: true
          description: CSRF protection state parameter
          schema:
            type: string
      responses:
        '302':
          description: Redirect to developer dashboard on success
          headers:
            Location:
              description: Developer dashboard URL
              schema:
                type: string
                format: uri
        '400':
          description: Invalid state or GitHub error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /healthz:
    get:
      operationId: healthLiveness
      summary: Liveness probe
      description: Check if the service is alive and running. Used by orchestrators (Kubernetes, etc.) to determine if the service should be restarted.
      tags: [Health]
      security: []
      responses:
        '200':
          description: Service is alive
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ok

  /readyz:
    get:
      operationId: healthReadiness
      summary: Readiness probe
      description: Check if the service is ready to accept traffic. Verifies database connectivity and other dependencies.
      tags: [Health]
      security: []
      responses:
        '200':
          description: Service is ready
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: ready
                  checks:
                    type: object
                    properties:
                      database:
                        type: string
        '503':
          description: Service not ready

  /.well-known/sel-profile:
    get:
      operationId: getSELProfile
      summary: SEL node profile discovery
      description: |
        Returns this node's SEL profile information for federation discovery.
        Required by SEL Interoperability Profile §1.7.
      tags: [Health]
      security: []
      responses:
        '200':
          description: SEL profile information
          content:
            application/json:
              schema:
                type: object
                required: [profile, version, node, updated]
                properties:
                  profile:
                    type: string
                    format: uri
                    example: https://togather.foundation/sel/profiles/v0.1
                  version:
                    type: string
                    example: "0.1.0"
                  node:
                    type: string
                    format: uri
                    example: https://toronto.togather.foundation
                  updated:
                    type: string
                    format: date-time

  /openapi.json:
    get:
      operationId: getOpenApiSpec
      summary: OpenAPI specification
      description: Retrieve the OpenAPI 3.1 specification for this API in JSON format. Useful for API documentation and client generation.
      tags: [Health]
      security: []
      responses:
        '200':
          description: OpenAPI 3.1 specification
          content:
            application/json:
              schema:
                type: object

  /admin/scraper/sources:
    get:
      operationId: listScraperSources
      summary: List scraper sources
      description: |
        Returns all scraper sources with their latest run summary (status, counts,
        error message). Supports optional filtering by enabled state.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: enabled
          in: query
          description: Filter by enabled state. Omit to return all sources.
          schema:
            type: boolean
      responses:
        '200':
          description: List of scraper sources
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/ScraperSourceSummary'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/scraper/sources/{name}:
    patch:
      operationId: setScraperSourceEnabled
      summary: Enable or disable a scraper source
      description: |
        Enables or disables the named scraper source. Returns the updated source.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: name
          in: path
          required: true
          description: Scraper source name
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [enabled]
              properties:
                enabled:
                  type: boolean
      responses:
        '200':
          description: Updated scraper source
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScraperSourceSummary'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/scraper/sources/{name}/runs:
    get:
      operationId: listScraperSourceRuns
      summary: List run history for a scraper source
      description: |
        Returns the most recent runs for the named scraper source (up to 50).
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: name
          in: path
          required: true
          description: Scraper source name
          schema:
            type: string
      responses:
        '200':
          description: List of scraper runs
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/ScraperRunDetail'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/scraper/sources/{name}/diagnostics:
    get:
      operationId: getSourceDiagnostics
      summary: Get diagnostics for a scraper source
      description: |
        Returns the latest run, last successful run (for comparison when latest failed),
        and a configurable list of recent runs for the named scraper source.

        Smart default behavior:
        - Always includes the latest run
        - When the latest run failed, includes the last successful run for comparison
        - Returns empty response (200) if the source has no runs yet

        Query parameters:
        - `limit`: Number of recent runs to return (1–100, default 10)
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: name
          in: path
          required: true
          description: Scraper source name
          schema:
            type: string
        - name: limit
          in: query
          description: Number of recent runs to return (1–100, default 10)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 10
      responses:
        '200':
          description: Source diagnostics with latest run, last success, and recent runs
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SourceDiagnostics'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/scraper/diagnostics:
    get:
      operationId: getAllDiagnostics
      summary: Get cross-source scraper diagnostics
      description: |
        Returns recent scraper runs across all sources with optional filters.
        Useful for troubleshooting failures across multiple sources at once.

        Query parameters:
        - `limit`: Number of runs to return (1–100, default 20)
        - `status`: Filter by run status (failed, completed, running)
        - `source_name`: Filter by specific source name
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          description: Number of runs to return (1–100, default 20)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 20
        - name: status
          in: query
          description: Filter by run status
          schema:
            type: string
            enum: [failed, completed, running]
        - name: source_name
          in: query
          description: Filter by source name
          schema:
            type: string
      responses:
        '200':
          description: Filtered list of recent scraper runs
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AllDiagnostics'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/scraper/sources/{name}/trigger:
    post:
      operationId: triggerScraperSource
      summary: Trigger an immediate scrape for a source
      description: |
        Dispatches a background scrape for the named source and returns 202
        immediately. Returns 503 if this node has no scraper configured, and 404
        if the source name is unknown.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: name
          in: path
          required: true
          description: Scraper source name
          schema:
            type: string
      responses:
        '202':
          description: Scrape triggered
          content:
            application/json:
              schema:
                type: object
                properties:
                  source_name:
                    type: string
                  status:
                    type: string
                    example: triggered
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '503':
          description: Scraper not configured on this node
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/scraper/config:
    get:
      operationId: getScraperConfig
      summary: Get global scraper configuration
      description: |
        Returns the current global scraper configuration including concurrency limits,
        timeouts, retry settings, and rate limiting. If no config exists in the database,
        returns sensible defaults.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Current scraper configuration
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScraperConfig'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

    patch:
      operationId: patchScraperConfig
      summary: Update global scraper configuration
      description: |
        Updates the global scraper configuration with partial JSON. Only provided fields
        are applied over the current config. All numeric fields must be greater than 0
        (except rate_limit_ms which can be 0).

        Validation rules:
        - max_concurrent_sources: must be > 0
        - request_timeout_seconds: must be > 0
        - retry_max_attempts: must be > 0
        - max_batch_size: must be > 0
        - rate_limit_ms: must be >= 0
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/ScraperConfigPatch'
      responses:
        '200':
          description: Updated scraper configuration
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ScraperConfig'
        '400':
          description: Validation error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/scraper/trigger-all:
    post:
      operationId: triggerAllScraperSources
      summary: Trigger a serial scrape run for all eligible sources
      description: |
        Queues an orchestrator job that serially scrapes all enabled sources with
        daily or weekly schedules. Returns immediately with 202 after queuing.

        Serial chaining timeout behavior is configurable via:
        - `SCRAPER_SOURCE_JOB_TIMEOUT_SECONDS` (default 300)
        - `SCRAPER_CHAIN_ENQUEUE_TIMEOUT_MS` (default 5000)
        - `SCRAPER_CHAIN_ENQUEUE_RETRIES` (default 3)

        Options:
        - `respect_auto_scrape` (default: true): If true and global auto_scrape is disabled, skip the run
        - `skip_up_to_date` (default: true): Skip sources with fresh runs (daily <24h, weekly <7d)
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                respect_auto_scrape:
                  type: boolean
                  default: true
                skip_up_to_date:
                  type: boolean
                  default: true
      responses:
        '202':
          description: Orchestrator job queued
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: triggered
                  respect_auto_scrape:
                    type: boolean
                  skip_up_to_date:
                    type: boolean
                  orchestrator_job_id:
                    type: integer
        '200':
          description: Run skipped (auto_scrape disabled and respect_auto_scrape=true)
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: skipped
                  respect_auto_scrape:
                    type: boolean
                  skip_up_to_date:
                    type: boolean
        '409':
          description: Run already in progress; overlapping run-all is blocked
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: already_running
                  respect_auto_scrape:
                    type: boolean
                  skip_up_to_date:
                    type: boolean
                  running_sources:
                    type: integer
                    minimum: 1
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '503':
          description: |
            Job queue not available or orchestrator not configured.
            This can occur if:
            - The River job queue is unavailable on this node
            - The scraper orchestrator dependencies are not wired (e.g., River not initialized)
            Check that the server is configured with `RIVER_ENABLED=true` and the database
            schema includes River tables.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /scraper/submissions:
    post:
      operationId: submitURLs
      summary: Submit URLs for scraping
      description: |
        Submits up to 10 URLs for review and eventual scraping. No authentication
        required. Rate limited to 20 URLs per IP per 24 hours (configurable via
        `RATE_LIMIT_SUBMISSIONS_PER_IP_PER_24H`).

        Each URL is normalised and deduplicated before insertion. The response
        includes a per-URL result indicating whether the URL was accepted,
        already exists (duplicate), or rejected (invalid URL).
      tags: [Scraper]
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [urls]
              properties:
                urls:
                  type: array
                  items:
                    type: string
                    format: uri
                  minItems: 1
                  maxItems: 10
                  example:
                    - https://www.toronto.ca/events-calendar/
            example:
              urls:
                - https://www.toronto.ca/events-calendar/
      responses:
        '200':
          description: Submission results, one entry per submitted URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      $ref: '#/components/schemas/SubmissionResult'
        '400':
          $ref: '#/components/responses/BadRequest'
        '429':
          description: Rate limit exceeded — retry after 24 hours
          headers:
            Retry-After:
              schema:
                type: integer
                example: 86400
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/scraper/events:
    get:
      operationId: streamScraperEvents
      summary: Stream scraper job events
      description: |
        Server-Sent Events stream of River job lifecycle events for scraper jobs.
        Clients receive events when scrape_source jobs complete, fail, or are cancelled.
        Maintains a keepalive comment every 15 seconds to prevent proxy timeouts.
        Protected by admin cookie auth (same-origin EventSource sends cookies automatically).
      tags: [Admin]
      x-internal: true
      security:
        - cookieAuth: []
      responses:
        '200':
          description: SSE stream of scraper job events
          content:
            text/event-stream:
              schema:
                type: string
                description: |
                  Newline-delimited SSE stream. Each event has the form:
                    id: <job_id>
                    data: {"kind":"job_completed","job_kind":"scrape_source","source_name":"<name>","job_id":<id>}

                  Keepalive comments (`: keepalive`) are sent every 15 seconds.
                  A `retry: 5000` directive is sent on connection.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          description: Streaming not supported

  /admin/scraper/submissions:
    get:
      operationId: listScraperSubmissions
      summary: List URL submissions
      description: |
        Returns a paginated list of URL submissions. Optionally filter by status.
        Useful for the admin review queue.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          description: |
            Filter by submission status. One of: `pending_validation`, `pending`,
            `processed`, `rejected`.
          schema:
            type: string
            enum: [pending_validation, pending, processed, rejected]
        - name: limit
          in: query
          description: Maximum number of results (1–200, default 50).
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
        - name: offset
          in: query
          description: Number of results to skip (default 0).
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Paginated list of submissions
          content:
            application/json:
              schema:
                type: object
                properties:
                  submissions:
                    type: array
                    items:
                      $ref: '#/components/schemas/Submission'
                  total:
                    type: integer
                    description: Total number of submissions matching the filter
                  limit:
                    type: integer
                  offset:
                    type: integer
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

  /admin/scraper/submissions/{id}:
    patch:
      operationId: updateScraperSubmission
      summary: Review a URL submission
      description: |
        Marks a submission as `processed` (accepted for scraping) or `rejected`,
        optionally adding admin notes. Only submissions with status `pending` may
        be reviewed.
      tags: [Admin]
      x-internal: true
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: integer
            format: int64
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status:
                  type: string
                  enum: [processed, rejected]
                  description: New status for the submission
                notes:
                  type: [string, 'null']
                  description: Optional admin notes
            example:
              status: processed
              notes: "Good source — add to weekly scrape rotation"
      responses:
        '200':
          description: Updated submission
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Submission'
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFound'
        '500':
          description: Internal server error
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/ProblemDetails'

components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: API key for agent access
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: JWT token for admin access
    cookieAuth:
      type: apiKey
      in: cookie
      name: auth_token
      description: Admin JWT token in HttpOnly cookie (used by same-origin SSE endpoints)
    devCookie:
      type: apiKey
      in: cookie
      name: dev_auth_token
      description: Developer JWT token in HttpOnly cookie
    devJWT:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: Developer JWT token for API access

  parameters:
    eventId:
      name: id
      in: path
      required: true
      description: Event ULID
      schema:
        type: string
        pattern: '^[0-9A-HJKMNP-TV-Z]{26}$'

    startDate:
      name: startDate
      in: query
      description: >
        Filter events starting on or after this date (ISO8601, e.g. 2026-06-01).
        Defaults to today when neither startDate nor endDate is provided, so past events
        are excluded by default. "Today" is midnight in the server timezone
        (configurable via DEFAULT_TIMEZONE, default America/Toronto).
        Pass an explicit startDate to override.
        Snake_case alias start_date is accepted but triggers a warnings entry in the response.
      schema:
        type: string
        format: date

    endDate:
      name: endDate
      in: query
      description: >
        Filter events starting on or before this date (ISO8601, e.g. 2026-06-30).
        Snake_case alias end_date is accepted but triggers a warnings entry in the response.
      schema:
        type: string
        format: date

    city:
      name: city
      in: query
      description: Filter by city/locality
      schema:
        type: string

    venueId:
      name: venueId
      in: query
      description: >
        Filter by venue ULID.
        Snake_case alias venue_id is accepted but triggers a warnings entry in the response.
      schema:
        type: string

    organizerId:
      name: organizerId
      in: query
      description: >
        Filter by organizer ULID.
        Snake_case alias organizer_id is accepted but triggers a warnings entry in the response.
      schema:
        type: string

    lifecycleState:
      name: state
      in: query
      description: Filter by lifecycle state
      schema:
        type: string
        enum: [draft, published, postponed, rescheduled, sold_out, cancelled, completed]

    eventDomain:
      name: domain
      in: query
      description: Filter by event domain
      schema:
        type: string
        enum: [arts, music, culture, sports, community, education, general]

    query:
      name: q
      in: query
      description: Free-text search on name and description
      schema:
        type: string

    keywords:
      name: keywords
      in: query
      description: Filter by keywords (comma-separated)
      schema:
        type: string

    after:
      name: after
      in: query
      description: |
        Opaque cursor for pagination (obtained from previous response's `next_cursor`).
        Cursors are base64url-encoded strings - treat as opaque, do not parse or modify.
        Example: `MTcwNjg4NjAwMDAwMDAwMDAwMDowMUhZWDNLUVc3RVJUV jlYTkJNMlA4UUpaRg`
      schema:
        type: string
        example: "MTcwNjg4NjAwMDAwMDAwMDAwMDowMUhZWDNLUVc3RVJUV jlYTkJNMlA4UUpaRg"

    limit:
      name: limit
      in: query
      description: Maximum results per page (default 50, max 200)
      schema:
        type: integer
        minimum: 1
        maximum: 200
        default: 50

  schemas:
    ConsolidateResponse:
      type: object
      description: Result of an atomic event consolidation operation
      properties:
        event:
          description: The canonical event after consolidation
          $ref: '#/components/schemas/Event'
        lifecycle_state:
          type: string
          enum: [published, pending_review, draft]
          description: Lifecycle state of the canonical event after consolidation
        is_duplicate:
          type: boolean
          description: >
            True if the canonical event was flagged as a near-duplicate of another
            non-retired event during post-consolidation validation.
        is_merged:
          type: boolean
          description: >
            Always false for consolidation (admin intent is preserved, never auto-merged).
        needs_review:
          type: boolean
          description: >
            True if the canonical event was sent to the review queue due to duplicate
            detection or quality issues found during post-consolidation validation.
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/ValidationWarning'
          description: Validation warnings from post-consolidation checks
        retired:
          type: array
          items:
            type: string
          description: ULIDs of events that were successfully retired (soft-deleted)
        review_entries_dismissed:
          type: array
          items:
            type: integer
          description: IDs of review queue entries that were dismissed for retired events

    OccurrenceInput:
      type: object
      description: Input for creating or updating an event occurrence
      properties:
        start_time:
          type: string
          format: date-time
          description: Start time of the occurrence (RFC3339)
        end_time:
          type: [string, 'null']
          format: date-time
          description: End time of the occurrence (RFC3339), or null to clear
        door_time:
          type: [string, 'null']
          format: date-time
          description: Door open time (RFC3339), or null to clear
        timezone:
          type: string
          description: IANA timezone identifier (e.g. America/New_York)
        venue_ulid:
          type: [string, 'null']
          description: ULID of the venue for this occurrence, or null to clear
        virtual_url:
          type: [string, 'null']
          format: uri
          description: URL for virtual/hybrid attendance, or null to clear
        ticket_url:
          type: [string, 'null']
          format: uri
          description: URL for purchasing tickets, or null to clear
        price_min:
          type: [number, 'null']
          description: Minimum ticket price (inclusive), or null to clear
        price_max:
          type: [number, 'null']
          description: Maximum ticket price, or null to clear
        price_currency:
          type: string
          description: ISO 4217 currency code (e.g. USD)
        availability:
          type: string
          enum: [available, sold_out, limited]
          description: Ticket availability status

    OccurrenceResponse:
      type: object
      description: An event occurrence as returned by the admin occurrence API
      properties:
        id:
          type: string
          format: uuid
          description: UUID of the occurrence
        start_time:
          type: string
          format: date-time
        end_time:
          type: [string, 'null']
          format: date-time
        door_time:
          type: [string, 'null']
          format: date-time
        timezone:
          type: string
        venue_id:
          type: [string, 'null']
          description: Internal UUID of the venue
        venue_ulid:
          type: [string, 'null']
          description: Public ULID of the venue
        virtual_url:
          type: [string, 'null']
          format: uri
        ticket_url:
          type: [string, 'null']
          format: uri
        price_min:
          type: [number, 'null']
        price_max:
          type: [number, 'null']
        price_currency:
          type: [string, 'null']
        availability:
          type: [string, 'null']

    Submission:
      type: object
      description: A URL submission record
      properties:
        id:
          type: integer
          format: int64
        url:
          type: string
          format: uri
          description: Original submitted URL
        url_norm:
          type: string
          format: uri
          description: Normalised URL (trailing slash stripped, lowercased scheme/host)
        submitted_at:
          type: string
          format: date-time
        submitter_ip:
          type: string
          description: IP address of the submitter
        status:
          type: string
          enum: [pending_validation, pending, processed, rejected]
        rejection_reason:
          type: [string, 'null']
          description: Set when the batch validator rejects the URL
        notes:
          type: [string, 'null']
          description: Admin review notes
        validated_at:
          type: [string, 'null']
          format: date-time
          description: Timestamp when the batch validator processed this submission

    SubmissionResult:
      type: object
      description: Per-URL result from the public submission endpoint
      properties:
        url:
          type: string
          format: uri
          description: The URL that was submitted
        status:
          type: string
          enum: [accepted, duplicate, rejected]
          description: |
            `accepted` — stored for validation; `duplicate` — already submitted
            recently; `rejected` — failed basic validation (invalid URL, etc.)
        message:
          type: string
          description: Human-readable explanation

    ScraperSourceSummary:
      type: object
      description: A scraper source with its latest run summary
      properties:
        id:
          type: integer
        name:
          type: string
        url:
          type: string
          format: uri
        tier:
          type: integer
        schedule:
          type: string
        license:
          type: string
        enabled:
          type: boolean
        last_run_status:
          type: string
        last_run_started_at:
          type: string
          format: date-time
        last_run_completed_at:
          type: string
          format: date-time
        last_run_events_found:
          type: integer
        last_run_events_new:
          type: integer
        last_run_events_dup:
          type: integer
        last_run_events_failed:
          type: integer
        last_run_error_message:
          type: string

    ScraperConfig:
      type: object
      description: Global scraper configuration settings
      properties:
        auto_scrape:
          type: boolean
          description: Whether automatic scheduled scraping is enabled
          default: true
        max_concurrent_sources:
          type: integer
          description: Maximum number of sources to scrape concurrently
          default: 3
        request_timeout_seconds:
          type: integer
          description: HTTP request timeout in seconds
          default: 30
        retry_max_attempts:
          type: integer
          description: Maximum retry attempts for failed requests
          default: 3
        max_batch_size:
          type: integer
          description: Maximum number of events to batch per source
          default: 100
        rate_limit_ms:
          type: integer
          description: Minimum delay between requests in milliseconds
          default: 0

    ScraperConfigPatch:
      type: object
      description: Partial scraper configuration for PATCH requests (all fields optional)
      properties:
        auto_scrape:
          type: boolean
        max_concurrent_sources:
          type: integer
          description: Must be greater than 0
        request_timeout_seconds:
          type: integer
          description: Must be greater than 0
        retry_max_attempts:
          type: integer
          description: Must be greater than 0
        max_batch_size:
          type: integer
          description: Must be greater than 0
        rate_limit_ms:
          type: integer
          description: Must be 0 or greater

    ScraperRunDetail:
      type: object
      description: A single scraper run record
      properties:
        id:
          type: integer
        source_name:
          type: string
        source_url:
          type: string
          format: uri
        tier:
          type: integer
        status:
          type: string
        started_at:
          type: string
          format: date-time
        completed_at:
          type: string
          format: date-time
        events_found:
          type: integer
        events_new:
          type: integer
        events_dup:
          type: integer
        events_failed:
          type: integer
        error_message:
          type: string

    SourceDiagnostics:
      type: object
      description: Diagnostics for a single scraper source including latest run, last successful run, and recent history
      properties:
        source_name:
          type: string
        latest_run:
          oneOf:
            - $ref: '#/components/schemas/ScraperRunDetail'
            - type: 'null'
          description: The most recent run for this source (null if no runs exist)
        last_successful_run:
          oneOf:
            - $ref: '#/components/schemas/ScraperRunDetail'
            - type: 'null'
          description: The most recent completed run (included when latest_run failed, for comparison)
        recent_runs:
          type: array
          items:
            $ref: '#/components/schemas/ScraperRunDetail'
          description: Recent runs ordered newest first (limited by query param)

    AllDiagnostics:
      type: object
      description: Cross-source diagnostics with filtered recent runs
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ScraperRunDetail'
        total:
          type: integer
          description: Total number of runs returned

    Event:
      type: object
      description: A cultural event in Schema.org Event format with SEL extensions for provenance and federation
      required: ['@id', '@type', name, startDate, location]
      properties:
        '@context':
          oneOf:
            - type: string
            - type: array
              items:
                type: string
            - type: object
          description: |
            JSON-LD context. May be a string URL, an array of URLs, or an embedded context object.
            Per JSON-LD specification, all three forms are valid.
        '@id':
          type: string
          format: uri
          description: Canonical URI for this event
        '@type':
          type: string
          enum: [Event, EventSeries]
        name:
          type: string
        description:
          type: string
        eventStatus:
          type: string
          format: uri
        eventAttendanceMode:
          type: string
          format: uri
        startDate:
          type: string
          format: date-time
        endDate:
          type: string
          format: date-time
        doorTime:
          type: string
          format: date-time
        location:
          $ref: '#/components/schemas/Place'
        organizer:
          $ref: '#/components/schemas/Organization'
        image:
          type: string
          format: uri
        url:
          type: string
          format: uri
        inLanguage:
          type: array
          items:
            type: string
        isAccessibleForFree:
          type: boolean
        offers:
          $ref: '#/components/schemas/Offer'
        sameAs:
          type: array
          items:
            type: string
            format: uri
        'sel:originNode':
          type: string
          format: uri
        'sel:ingestedAt':
          type: string
          format: date-time
        'sel:confidence':
          type: number
          minimum: 0
          maximum: 1
        license:
          type: string
          format: uri

    EventInput:
      type: object
      description: Input schema for creating or updating an event. Minimal required fields with optional Schema.org properties
      required: [name, startDate]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 500
          description: Event name (configurable via VALIDATION_MAX_EVENT_NAME_LENGTH, default 500)
        description:
          type: string
          maxLength: 10000
        startDate:
          type: string
          format: date-time
        endDate:
          type: string
          format: date-time
        doorTime:
          type: string
          format: date-time
        location:
          oneOf:
            - $ref: '#/components/schemas/PlaceInput'
            - $ref: '#/components/schemas/VirtualLocationInput'
        virtualLocation:
          $ref: '#/components/schemas/VirtualLocationInput'
        organizer:
          $ref: '#/components/schemas/OrganizationInput'
        image:
          type: string
          format: uri
        url:
          type: string
          format: uri
        keywords:
          type: array
          items:
            type: string
        inLanguage:
          type: array
          items:
            type: string
        isAccessibleForFree:
          type: boolean
        offers:
          $ref: '#/components/schemas/OfferInput'
        source:
          $ref: '#/components/schemas/SourceInput'

    EventUpdateInput:
      description: Schema for updating an existing event with version control for optimistic locking
      allOf:
        - $ref: '#/components/schemas/EventInput'
        - type: object
          properties:
            version:
              type: integer
              description: Current version for optimistic locking
            lifecycleState:
              type: string
              enum: [draft, published, postponed, rescheduled, sold_out, cancelled, completed]

    Place:
      description: A physical location or venue where events occur, using Schema.org Place vocabulary
      type: object
      required: ['@type', name]
      properties:
        '@id':
          type: string
          format: uri
        '@type':
          type: string
          enum: [Place]
        name:
          type: string
        address:
          $ref: '#/components/schemas/PostalAddress'
        geo:
          $ref: '#/components/schemas/GeoCoordinates'
        telephone:
          type: string
        url:
          type: string
          format: uri
        sameAs:
          type: array
          items:
            type: string
            format: uri

    PlaceInput:
      description: Input schema for specifying event locations with address and geographic coordinates
      type: object
      required: [name]
      properties:
        '@id':
          type: string
          format: uri
          description: Reference to existing place by URI
        name:
          type: string
        streetAddress:
          type: string
        addressLocality:
          type: string
        addressRegion:
          type: string
        postalCode:
          type: string
        addressCountry:
          type: string
        latitude:
          type: number
        longitude:
          type: number

    VirtualLocationInput:
      description: Input schema for online/virtual event locations with URL
      type: object
      required: [url]
      properties:
        '@type':
          type: string
          enum: [VirtualLocation]
        url:
          type: string
          format: uri
        name:
          type: string

    PostalAddress:
      description: Physical mailing address using Schema.org PostalAddress vocabulary
      type: object
      properties:
        '@type':
          type: string
          enum: [PostalAddress]
        streetAddress:
          type: string
        addressLocality:
          type: string
        addressRegion:
          type: string
        postalCode:
          type: string
        addressCountry:
          type: string

    GeoCoordinates:
      description: Geographic coordinates (latitude/longitude) using Schema.org GeoCoordinates vocabulary
      type: object
      properties:
        '@type':
          type: string
          enum: [GeoCoordinates]
        latitude:
          type: number
        longitude:
          type: number

    Organization:
      description: An organization or person that organizes events, using Schema.org Organization vocabulary
      type: object
      required: ['@type', name]
      properties:
        '@id':
          type: string
          format: uri
        '@type':
          type: string
          enum: [Organization]
        name:
          type: string
        alternateName:
          type: string
        url:
          type: string
          format: uri
        sameAs:
          type: array
          items:
            type: string
            format: uri

    OrganizationInput:
      description: Input schema for specifying event organizers
      type: object
      required: [name]
      properties:
        '@id':
          type: string
          format: uri
        name:
          type: string
        url:
          type: string
          format: uri

    Offer:
      description: Ticketing or pricing information using Schema.org Offer vocabulary
      type: object
      properties:
        '@type':
          type: string
          enum: [Offer]
        url:
          type: string
          format: uri
        price:
          type: string
        priceCurrency:
          type: string
        availability:
          type: string
          format: uri

    OfferInput:
      description: Input schema for ticket/pricing offers
      type: object
      properties:
        url:
          type: string
          format: uri
        price:
          type: string
        priceCurrency:
          type: string
          default: CAD

    SourceInput:
      description: Provenance metadata identifying the data source and attribution for submitted events
      type: object
      properties:
        url:
          type: string
          format: uri
          description: Source URL for provenance
        eventId:
          type: string
          description: External system's event ID

    EventListResponse:
      description: Paginated list of events with cursor-based pagination
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Event'
        next_cursor:
          type: string
          description: Cursor for next page
        warnings:
          type: array
          items:
            type: string
          description: >
            Parameter alias warnings. Present when a snake_case alias (e.g. start_date) was
            used instead of the canonical camelCase name. Each entry names the alias used and
            the correct parameter name. Omitted when no aliases were detected.

    PlaceListResponse:
      description: Paginated list of places (venues) with cursor-based pagination
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Place'
        next_cursor:
          type: string
        warnings:
          type: array
          items:
            type: string
          description: Parameter alias warnings. See EventListResponse.warnings for details.

    OrganizationListResponse:
      description: Paginated list of organizations with cursor-based pagination
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/Organization'
        next_cursor:
          type: string
        warnings:
          type: array
          items:
            type: string
          description: Parameter alias warnings. See EventListResponse.warnings for details.

    ChangeFeedResponse:
      description: Federation change feed containing sequential updates for event synchronization
      type: object
      required: [cursor, changes]
      properties:
        cursor:
          type: string
          description: Current cursor position
        changes:
          type: array
          items:
            $ref: '#/components/schemas/ChangeEntry'
        next_cursor:
          type: string
          description: Cursor for next page

    ChangeEntry:
      description: Single change record in the federation feed with action type and snapshot
      type: object
      required: [action, uri, changed_at]
      properties:
        action:
          type: string
          enum: [create, update, delete]
        uri:
          type: string
          format: uri
        changed_at:
          type: string
          format: date-time
        changed_fields:
          type: array
          items:
            type: string
        snapshot:
          $ref: '#/components/schemas/Event'

    ApiKeyInfo:
      description: API key metadata (without secret) for agent authentication management
      type: object
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        prefix:
          type: string
          description: First 8 chars of key
        sourceId:
          type: string
          format: uuid
        createdAt:
          type: string
          format: date-time
        lastUsedAt:
          type: string
          format: date-time
        expiresAt:
          type: string
          format: date-time
        isActive:
          type: boolean

    ApiKeyCreated:
      description: Response when creating a new API key, includes the secret (shown only once)
      allOf:
        - $ref: '#/components/schemas/ApiKeyInfo'
        - type: object
          required: [key]
          properties:
            key:
              type: string
              description: Full API key (shown only once)

    ReviewQueueListResponse:
      description: Paginated list of events awaiting admin review
      type: object
      required: [items]
      properties:
        items:
          type: array
          items:
            $ref: '#/components/schemas/ReviewQueueItem'
        next_cursor:
          type: string
          description: Cursor for next page (review queue entry ID)

    ReviewQueueItem:
      description: Summary of a review queue entry with event ID and reason for review
      type: object
      required: [id, eventId, warnings, status, createdAt]
      properties:
        id:
          type: integer
          description: Review queue entry ID
        eventId:
          type: string
          description: Event ULID
        eventName:
          type: string
          description: Name of the event (from original payload)
        eventStartTime:
          type: string
          format: date-time
          description: Event start time
        eventEndTime:
          type: string
          format: date-time
          description: Event end time
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/ValidationWarning'
          description: Validation warnings that triggered review
        status:
          type: string
          enum: [pending, approved, rejected, merged]
          description: >
            Current review status. `pending` = awaiting decision; `approved` = event published;
            `rejected` = event deleted; `merged` = event absorbed into another event
            (via add-occurrence or consolidation).
        createdAt:
          type: string
          format: date-time
          description: When the entry was created
        reviewedBy:
          type: string
          description: Email of admin who reviewed (if reviewed)
        reviewedAt:
          type: string
          format: date-time
          description: When the review was completed (if reviewed)
        duplicateOfEventUlid:
          type: string
          description: >
            ULID of the primary event that this review entry was merged into or linked as a
            near-duplicate of. Set when the review action is add-occurrence or consolidation,
            and also when near-duplicate cross-linking creates a review entry for an existing
            published event.

        occurrenceCount:
          type: integer
          description: >
            Number of occurrences for the event. Fetched from the live event data
            to reflect current occurrence information.

    ReviewQueueDetail:
      description: Detailed review queue entry including original payload, normalized payload, and detected changes
      type: object
      required: [id, eventId, status, warnings, original, normalized, changes, createdAt, occurrences, relatedEvents]
      properties:
        id:
          type: integer
          description: Review queue entry ID
        eventId:
          type: string
          description: Event ULID
        status:
          type: string
          enum: [pending, approved, rejected, merged]
          description: >
            Current review status. `pending` = awaiting decision; `approved` = event published;
            `rejected` = event deleted; `merged` = event absorbed into another event
            (via add-occurrence or consolidation).
        warnings:
          type: array
          items:
            $ref: '#/components/schemas/ValidationWarning'
          description: Validation warnings that triggered review
        original:
          type: object
          description: Original event payload as submitted
        normalized:
          type: object
          description: Normalized event payload after corrections
        changes:
          type: array
          items:
            $ref: '#/components/schemas/ChangeDetail'
          description: List of changes made during normalization
        createdAt:
          type: string
          format: date-time
          description: When the entry was created
        reviewedBy:
          type: string
          description: Email of admin who reviewed (if reviewed)
        reviewedAt:
          type: string
          format: date-time
          description: When the review was completed (if reviewed)
        reviewNotes:
          type: string
          description: Notes from the reviewer (if approved with notes)
        rejectionReason:
          type: string
          description: Reason for rejection (if rejected)
        duplicateOfEventUlid:
          type: string
          description: >
            ULID of the primary event that this review entry was merged into or linked as a
            near-duplicate of. Set when the review action is add-occurrence or consolidation,
            and also when near-duplicate cross-linking creates a review entry for an existing
            published event.
        occurrences:
          type: array
          items:
            $ref: '#/components/schemas/EventOccurrence'
          description: List of occurrences for the event associated with this review entry
        relatedEvents:
          type: array
          items:
            $ref: '#/components/schemas/RelatedEvent'
          description: Related events identified from warnings or duplicate link

    EventOccurrence:
      description: A single occurrence (date/time/venue) of an event
      type: object
      required: [id, startTime, timezone]
      properties:
        id:
          type: string
          description: Occurrence UUID
        startTime:
          type: string
          format: date-time
          description: Start date and time of the occurrence
        endTime:
          type: [string, 'null']
          format: date-time
          description: End date and time of the occurrence (optional)
        timezone:
          type: string
          description: IANA timezone identifier (e.g., America/New_York)
        doorTime:
          type: [string, 'null']
          format: date-time
          description: Doors open time (optional)
        venueUlid:
          type: [string, 'null']
          description: ULID of the venue (optional)
        virtualUrl:
          type: [string, 'null']
          description: Virtual event URL (optional)
        ticketUrl:
          type: string
          description: URL to purchase tickets (optional)
        priceMin:
          type: [number, 'null']
          format: float
          description: Minimum ticket price (optional)
        priceMax:
          type: [number, 'null']
          format: float
          description: Maximum ticket price (optional)
        priceCurrency:
          type: string
          description: ISO 4217 currency code (e.g., USD)
        availability:
          type: string
          description: Availability status (e.g., InStock, OutOfStock)

    RelatedEvent:
      description: A related event identified from warnings or duplicate link, including full event data for side-by-side comparison
      type: object
      required: [ulid]
      properties:
        ulid:
          type: string
          description: ULID of the related event
        name:
          type: string
          description: Event name
        description:
          type: [string, 'null']
          description: Event description (optional)
        url:
          type: [string, 'null']
          description: Public URL of the event (optional)
        imageUrl:
          type: [string, 'null']
          description: Image URL for the event (optional)
        venueName:
          type: [string, 'null']
          description: Name of the primary venue (optional)
        venueUlid:
          type: [string, 'null']
          description: ULID of the primary venue (optional)
        venueStreetAddress:
          type: [string, 'null']
          description: Street address of the venue (optional)
        venueCity:
          type: [string, 'null']
          description: City of the venue (optional)
        venueRegion:
          type: [string, 'null']
          description: Province/region of the venue (optional)
        venuePostalCode:
          type: [string, 'null']
          description: Postal code of the venue (optional)
        organizerName:
          type: [string, 'null']
          description: Name of the organizer (optional)
        organizerUrl:
          type: [string, 'null']
          description: URL of the organizer (optional)
        occurrences:
          type: array
          items:
            $ref: '#/components/schemas/EventOccurrence'
          description: List of occurrences for the related event
        similarity:
          type: [number, 'null']
          format: float
          description: Similarity score from duplicate-warning details, if available (0–1)

    ValidationWarning:
      description: Warning message from event validation or normalization process
      type: object
      required: [code, message]
      properties:
        occurrences:
          type: array
          items:
            $ref: '#/components/schemas/EventOccurrence'
          description: >
            List of occurrences for the event under review. These are fetched from the
            live event data (not from the ingest snapshot) to ensure the UI shows current
            occurrence information for dedup decision-making. Empty if the event has no
            occurrences (e.g., single-instance long-duration occurrence).
        relatedEvents:
          type: array
          items:
            $ref: '#/components/schemas/RelatedEvent'
          description: >
            List of related events extracted from validation warnings (potential_duplicate,
            near_duplicate_of_new_event) and from the duplicateOfEventUlid field. Each entry
            includes full event data (name, description, URL, venue, occurrences, similarity)
            so the UI can render the side-by-side comparison panel without additional API calls.
            Used to determine which case applies (Case 1: empty list, Case 2/3: non-empty list).
        code:
          type: string
          description: Warning code (e.g., "REVERSED_DATES", "MISSING_END_DATE")
        message:
          type: string
          description: Human-readable warning message
        field:
          type: string
          description: Field path that triggered the warning
        severity:
          type: string
          enum: [info, warning, error]
          description: Warning severity level

    ChangeDetail:
      description: Record of a single field change made during event normalization
      type: object
      required: [field, original, corrected, reason]
      properties:
        field:
          type: string
          description: Field that was changed (e.g., "endDate")
        original:
          description: Original value before normalization (can be any type)
          example: "2026-03-15T22:00:00Z"
          anyOf:
            - type: string
            - type: number
            - type: boolean
            - type: object
            - type: array
            - type: "null"
        corrected:
          description: Corrected value after normalization (can be any type)
          example: "2026-03-16T22:00:00Z"
          anyOf:
            - type: string
            - type: number
            - type: boolean
            - type: object
            - type: array
            - type: "null"
        reason:
          type: string
          description: Explanation of why the change was made

    AdminUserResponse:
      type: object
      required: [id, username, email, role, is_active, status, created_at]
      description: Admin user representation returned by user management endpoints.
      properties:
        id:
          type: string
          format: uuid
          description: User's unique identifier
        username:
          type: string
          description: User's login username
        email:
          type: string
          format: email
          description: User's email address
        role:
          type: string
          enum: [admin, editor, viewer]
          description: User's role
        is_active:
          type: boolean
          description: Whether the user account is active
        status:
          type: string
          enum: [active, inactive, pending]
          description: "Derived user status. active: account is active; pending: account created but invitation not yet accepted (no password set); inactive: account was active but has been deactivated"
        created_at:
          type: string
          format: date-time
          description: When the user account was created
        last_login_at:
          type: [string, "null"]
          format: date-time
          description: When the user last logged in, or null if never

    ProblemDetails:
      type: object
      required: [type, title, status]
      description: RFC 7807 Problem Details
      properties:
        type:
          type: string
          format: uri
          description: URI identifying the problem type
          example: https://sel.events/problems/max-keys-exceeded
        title:
          type: string
          description: Short, human-readable summary
          example: Maximum API keys exceeded
        status:
          type: integer
          description: HTTP status code
          example: 409
        detail:
          type: string
          description: Human-readable explanation
          example: You have reached your maximum of 5 API keys. Revoke an existing key or contact an admin to increase your limit.
        instance:
          type: string
          description: URI identifying the specific occurrence
          example: /api/v1/dev/api-keys

    Developer:
      type: object
      description: Developer account with API key management capabilities
      required: [id, email, name, max_keys, is_active, created_at]
      properties:
        id:
          type: string
          format: uuid
          description: Developer UUID
        email:
          type: string
          format: email
          description: Developer's email address
        name:
          type: string
          description: Developer's full name
        github_username:
          type: string
          description: GitHub username if linked via OAuth
        max_keys:
          type: integer
          minimum: 1
          maximum: 20
          description: Maximum API keys this developer can create
        is_active:
          type: boolean
          description: Whether the developer account is active
        created_at:
          type: string
          format: date-time
          description: When the developer account was created
        last_login_at:
          type: string
          format: date-time
          description: When the developer last logged in

    DeveloperAPIKey:
      type: object
      description: Developer-owned API key with usage statistics (secret not included)
      required: [id, name, prefix, role, is_active, created_at]
      properties:
        id:
          type: string
          format: uuid
          description: API key UUID
        name:
          type: string
          description: Human-readable name for the key
        prefix:
          type: string
          description: First 8 characters of the key for identification
          example: 01KGJZ40
        role:
          type: string
          enum: [agent]
          description: API key role (always 'agent' for developer keys)
        is_active:
          type: boolean
          description: Whether the key is currently active
        created_at:
          type: string
          format: date-time
          description: When the key was created
        last_used_at:
          type: string
          format: date-time
          description: When the key was last used
        usage_today:
          type: integer
          description: Number of requests made today
          example: 1523
        usage_7d:
          type: integer
          description: Number of requests in the last 7 days
          example: 8420
        usage_30d:
          type: integer
          description: Number of requests in the last 30 days
          example: 34210

    DeveloperAPIKeyCreated:
      type: object
      description: Response when creating a new API key, includes full secret (shown only once)
      required: [id, name, prefix, key, role, created_at]
      properties:
        id:
          type: string
          format: uuid
        name:
          type: string
        prefix:
          type: string
          example: 01KGJZ40
        key:
          type: string
          description: Full API key secret (shown only once on creation)
          example: 01KGJZ40ZG0WQ5SQ...
        role:
          type: string
          enum: [agent]
        created_at:
          type: string
          format: date-time

    APIKeyUsage:
      type: object
      description: Detailed usage statistics for an API key over a date range
      required: [api_key_id, api_key_name, period, total_requests, total_errors, daily]
      properties:
        api_key_id:
          type: string
          format: uuid
          description: API key UUID
        api_key_name:
          type: string
          description: API key name
        period:
          type: object
          required: [from, to]
          properties:
            from:
              type: string
              format: date
              description: Start date of usage period
            to:
              type: string
              format: date
              description: End date of usage period
        total_requests:
          type: integer
          description: Total number of requests in the period
          example: 34210
        total_errors:
          type: integer
          description: Total number of errors (4xx/5xx) in the period
          example: 142
        daily:
          type: array
          description: Daily breakdown of usage statistics
          items:
            type: object
            required: [date, request_count, error_count]
            properties:
              date:
                type: string
                format: date
              request_count:
                type: integer
                example: 1523
              error_count:
                type: integer
                example: 3

    Tombstone:
      type: object
      description: Response for deleted entities (HTTP 410)
      properties:
        '@context':
          type: string
        '@type':
          type: string
        '@id':
          type: string
          format: uri
        eventStatus:
          type: string
          format: uri
        'sel:tombstone':
          type: boolean
        'sel:deletedAt':
          type: string
          format: date-time
        'sel:deletionReason':
          type: string
        'sel:supersededBy':
          type: string
          format: uri

  responses:
    BadRequest:
      description: Invalid request
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'

    Unauthorized:
      description: Authentication required
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'

    Forbidden:
      description: Insufficient permissions
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'

    NotFound:
      description: Resource not found
      content:
        application/problem+json:
          schema:
            $ref: '#/components/schemas/ProblemDetails'

    Gone:
      description: Resource deleted
      content:
        application/ld+json:
          schema:
            $ref: '#/components/schemas/Tombstone'
