# ToldUntold Client API v1 — Analytics surface
# OpenAPI 3.1 description of /v1/client/analytics/*
#
# Formal, downloadable contract for integrators. Narrative integration guide:
# https://www.tolduntold.com/api-doc/analytics
openapi: 3.1.0

info:
  title: ToldUntold Client API — Analytics
  version: 1.0.0
  license:
    name: Proprietary — ToldUntold
    identifier: LicenseRef-Proprietary
  description: |
    Machine-to-machine REST API letting a brand's BI / ERP / HRIS systems pull
    its ToldUntold learning analytics: brand-level daily aggregates, content-level
    metrics, and pseudonymised user-level rows.

    ## Authentication
    Bearer authentication with a `tuk_` Client API key
    (`tuk_<key_id>.<secret>`), issued by a ToldUntold super-admin — the SAME key
    type as the Catalog surface. A single key can carry catalog and analytics
    scopes together. Legacy feed tokens (`cfk_`) are NOT valid and are rejected
    with `401`.

    ## Scopes
    - `analytics.metrics:read` — the aggregate datasets (no user dimension):
      `metrics/daily`, `metrics/contents`.
    - `analytics.users:read` — the pseudonymised user-level datasets:
      `users/daily`, `users/contents`. It is NOT implied by
      `analytics.metrics:read`; a key needs it explicitly.

    ## Conventions
    - **Errors** use a flat envelope `{ "code", "message", "errors"? }` where
      `code` is a stable machine-readable string.
    - **Offset lists** (`metrics/daily`) return `{ "data": [...], "meta": {...} }`
      with `page`/`per_page` pagination (`per_page` default 50, hard cap 200) and
      an exact `total`.
    - **Cursor lists** (`metrics/contents`, `users/daily`, `users/contents`)
      return `{ "data": [...], "meta": { "per_page", "next_cursor" } }`. The
      cursor is opaque and encrypted; there is no `total`. Follow `next_cursor`
      until it is `null`.
    - **Dates** are `YYYY-MM-DD` (UTC). `from`/`to` are inclusive day bounds.
    - **Rates & scores** are floats on a 0–100 scale. **Durations** are integer
      seconds (`*_seconds`).
    - **Rate limits** are advertised through `X-RateLimit-*` response headers.

    ## Data freshness
    Datasets are aggregated nightly and reflect complete days up to **J-1**
    (yesterday, UTC). The nightly aggregation runs roughly **00:05–06:00 UTC**;
    during that window the most recent day may still be in motion. Pull J-1 data
    **after 06:00 UTC**, and use `GET /status` `datasets.<key>.latest_date` as the
    freshness signal.

    ## Tenancy
    Each key carries exactly one brand. Every row is scoped to that brand; a
    market / content / user that belongs to another brand (or does not exist) is
    a flat `404 not_found`. There is no implicit cross-brand access.

servers:
  - url: https://api.{env}.tolduntold.com
    description: ToldUntold environment
    variables:
      env:
        default: staging
        description: Environment slug provided by ToldUntold (e.g. staging, production).

security:
  - bearerAuth: []

tags:
  - name: Status
    description: Key smoke-test and per-dataset freshness.
  - name: Metrics
    description: Aggregate analytics (no user dimension). Scope `analytics.metrics:read`.
  - name: Users
    description: Pseudonymised user-level analytics. Scope `analytics.users:read`.

paths:
  # ───────────────────────────── Status ─────────────────────────────
  /v1/client/analytics/status:
    get:
      tags: [Status]
      operationId: getAnalyticsStatus
      summary: Key smoke-test & dataset freshness
      description: |
        Echoes the authenticated key's identity, name, scopes and expiry, plus a
        per-dataset freshness block (`datasets.<key>.latest_date`, the latest
        ingested day for the brand). Requires a valid key but no particular scope.

        This endpoint NEVER returns `503`: if the analytics store is unreachable
        the freshness probe degrades to `datasets: null` while the identity block
        stays intact. Use it as your health check.

        `datasets` keys map to the data endpoints as: `daily` →
        `metrics/daily`, `contents` → `metrics/contents`, `users` →
        `users/daily`, `content_engagement` → `users/contents`. A
        `latest_date` of `null` means no data has been ingested for the brand yet.
      responses:
        '200':
          description: The authenticated key context and dataset freshness.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/KeyStatus' }
              examples:
                fresh:
                  summary: Store reachable — per-dataset freshness
                  value:
                    brand_id: 8347
                    key_id: tuk_9f2c1a4b6d8e0f2a4c6e8b0d
                    name: BI production
                    scopes: [analytics.metrics:read, analytics.users:read]
                    expires_at: null
                    datasets:
                      daily: { latest_date: '2026-07-08' }
                      contents: { latest_date: '2026-07-07' }
                      users: { latest_date: '2026-07-08' }
                      content_engagement: { latest_date: '2026-07-06' }
                degraded:
                  summary: Store unreachable — datasets null (still 200)
                  value:
                    brand_id: 8347
                    key_id: tuk_9f2c1a4b6d8e0f2a4c6e8b0d
                    name: BI production
                    scopes: [analytics.metrics:read]
                    expires_at: null
                    datasets: null
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }

  # ───────────────────────────── Metrics: daily ─────────────────────────────
  /v1/client/analytics/metrics/daily:
    get:
      tags: [Metrics]
      operationId: getDailyMetrics
      summary: Brand-level daily aggregates
      description: |
        Daily learning aggregates from `analytics_daily`, one row per day. Three
        mutually-exclusive granularities:

        - **brand-level** (default) — `market` is `null` on every row;
        - **single market** — `market=<market tu_id>`; rows for that market only;
        - **per-market breakdown** — `group_by=market`; one row per market per day
          (brand-level rows excluded). `market` and `group_by=market` may not be
          combined (→ `422 validation_failed`).

        Offset-paginated (`page`/`per_page`, default 50, cap 200) with an exact
        `total` — the volume is small and bounded (days × markets). Default window
        is the last **30 days** ending yesterday (UTC); the inclusive window may
        not exceed **366 days** (→ `422 date_range_too_large`).
      parameters:
        - { $ref: '#/components/parameters/From' }
        - { $ref: '#/components/parameters/To' }
        - $ref: '#/components/parameters/MarketFilter'
        - $ref: '#/components/parameters/GroupByMarket'
        - { $ref: '#/components/parameters/Page' }
        - { $ref: '#/components/parameters/PerPageOffset' }
      responses:
        '200':
          description: A page of daily aggregate rows.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/DailyMetricsList' }
              example:
                data:
                  - date: '2026-07-08'
                    market: null
                    live_content: { episodes: 5, programs: 3, quests: 2, products: 1 }
                    active_users: 42
                    totals: { views: 100, completions: 40, successes: 20, xp_earned: 1000, time_spent_seconds: 3600 }
                    episodes: { views: 60, completions: 25, successes: 12, time_spent_seconds: 1800, watch_rate: 75, completion_rate: 41, success_rate: 20, quiz_average_score: 80 }
                    programs: { views: 30, completions: 12, successes: 6, time_spent_seconds: 1200, watch_rate: 60, completion_rate: 40, success_rate: 20, quiz_average_score: 70 }
                    quests: { views: 10, completions: 3, successes: 2, time_spent_seconds: 600, watch_rate: 50, completion_rate: 30, success_rate: 20, quiz_average_score: 90 }
                    overall_completion_rate: 63
                    cumulative: { views: 5000, completions: 2000, successes: 1000, xp_earned: 50000 }
                meta: { current_page: 1, per_page: 50, total: 1, last_page: 1 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationOrRangeError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }

  # ───────────────────────────── Metrics: contents ─────────────────────────────
  /v1/client/analytics/metrics/contents:
    get:
      tags: [Metrics]
      operationId: getContentMetrics
      summary: Content-level metrics (brand-global)
      description: |
        Content-level rows from `learning_snapshots`, brand-global (market and POS
        aggregates are excluded). Cursor-paginated on the `(content_ref_id, date)`
        keyset. Default window is the last **30 days** ending yesterday (UTC); the
        inclusive window may not exceed **92 days** (→ `422 date_range_too_large`).

        Optional filters: `type` (`episode`|`program`) narrows to that content
        type; `content=<content tu_id>` reverses to a single content and REQUIRES
        `type` (a bare `content` is `422`). An unknown/cross-brand `content` is a
        flat `404`. Each row's `content` is the resolved `{ tu_id, type, name }`
        identity, or `null` when the reference is orphaned/cross-brand (the row is
        still returned). `market` and `pos` are always `null` on this dataset.
      parameters:
        - { $ref: '#/components/parameters/From' }
        - { $ref: '#/components/parameters/To' }
        - $ref: '#/components/parameters/ContentTypeFilter'
        - $ref: '#/components/parameters/ContentFilter'
        - { $ref: '#/components/parameters/PerPageCursor' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: A cursor page of content-level rows.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ContentMetricsList' }
              example:
                data:
                  - date: '2026-07-08'
                    content: { tu_id: epi_7c1a9f, type: episode, name: 'Onboarding — module 1' }
                    market: null
                    pos: null
                    metrics: { views: 15, unique_views: 12, completions: 8, successes: 5, active_users: 10, total_users: 20, engagement_rate: 55, xp_earned: 300, time_spent_seconds: 900, questions_count: 4, quiz_average_score: 77 }
                    cumulative: { views: 12345, completions: 400, successes: 250, watch_rate: 88, completion_rate: 66, success_rate: 44, quiz_average_score: 72 }
                meta: { per_page: 100, next_cursor: 'eyJ2IjoxLC...opaque...' }
        '400': { $ref: '#/components/responses/InvalidCursor' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationOrRangeError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }

  # ───────────────────────────── Users: daily ─────────────────────────────
  /v1/client/analytics/users/daily:
    get:
      tags: [Users]
      operationId: getUserDailyMetrics
      summary: Pseudonymised per-user daily rows
      description: |
        Brand-level per-user daily rows from `learning_users`, pseudonymised: the
        ONLY user identifier is the public `tu_id`. Cursor-paginated on the
        `(user_id, date)` keyset. Default window is the last **30 days** ending
        yesterday (UTC); the inclusive window may not exceed **92 days**
        (→ `422 date_range_too_large`).

        Optional `user=<user tu_id>` filter (unknown/excluded/cross-brand → an
        indistinguishable flat `404`). `market` and `pos` are always `null`
        (brand-level rows).

        **Privacy — sparse pages.** A per-page defensive filter re-applies the
        analytics-eligibility + brand-membership rules, so a user excluded AFTER
        the nightly aggregation is dropped from `data`. The cursor still advances
        on the last RAW row, so a page may legally be `data: []` with a non-null
        `next_cursor`. Keep following `next_cursor` until it is `null`.
      parameters:
        - { $ref: '#/components/parameters/From' }
        - { $ref: '#/components/parameters/To' }
        - $ref: '#/components/parameters/UserFilter'
        - { $ref: '#/components/parameters/PerPageCursor' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: A cursor page of pseudonymised per-user daily rows.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserDailyList' }
              example:
                data:
                  - date: '2026-01-15'
                    user: { tu_id: usr_3a9c1f }
                    market: null
                    pos: null
                    metrics:
                      views: 10
                      completions: 8
                      successes: 6
                      xp_earned: 1500
                      time_spent_seconds: 3600
                      overall_score: 87.5
                      episodes: { views: 5, completions: 4, successes: 3 }
                      programs: { views: 3, completions: 2, successes: 1 }
                      quests: { views: 2, completions: 1, successes: 0 }
                    cumulative:
                      views: 100
                      completions: 80
                      successes: 60
                      episodes: { views: 50, completions: 40, successes: 30 }
                      programs: { views: 30, completions: 20, successes: 10 }
                      quests: { views: 20, completions: 10, successes: 5 }
                meta: { per_page: 100, next_cursor: null }
        '400': { $ref: '#/components/responses/InvalidCursor' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationOrRangeError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }

  # ───────────────────────────── Users: contents ─────────────────────────────
  /v1/client/analytics/users/contents:
    get:
      tags: [Users]
      operationId: getUserContentMetrics
      summary: Pseudonymised user × content sync rows
      description: |
        User × content rows from `learning_content_users`, pseudonymised to
        `tu_id`. This is a high-volume SYNC dataset. Cursor-paginated on the
        `(content_ref_id, date, user_id)` keyset. Default window is the last
        **7 days** ending yesterday (UTC); the inclusive window may not exceed
        **92 days** (→ `422 date_range_too_large`).

        Optional filters: `user=<user tu_id>` (flat `404` on miss),
        `content=<content tu_id>` + `type` (`type` REQUIRED with `content`;
        flat `404` on miss), and `completed` (boolean) which is applied
        store-side so pages stay full. The `content` block carries `{ tu_id,
        type }` ONLY (no `name` label on this dataset), or `null` for an
        orphaned/cross-brand reference. The same sparse-page rule as `users/daily`
        applies (defensive per-page PII drops; follow `next_cursor` to `null`).
      parameters:
        - { $ref: '#/components/parameters/From' }
        - { $ref: '#/components/parameters/To' }
        - $ref: '#/components/parameters/UserFilter'
        - $ref: '#/components/parameters/ContentTypeFilter'
        - $ref: '#/components/parameters/ContentFilter'
        - $ref: '#/components/parameters/CompletedFilter'
        - { $ref: '#/components/parameters/PerPageCursor' }
        - { $ref: '#/components/parameters/Cursor' }
      responses:
        '200':
          description: A cursor page of pseudonymised user × content rows.
          headers:
            X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
          content:
            application/json:
              schema: { $ref: '#/components/schemas/UserContentList' }
              example:
                data:
                  - date: '2026-01-15'
                    user: { tu_id: usr_3a9c1f }
                    content: { tu_id: epi_7c1a9f, type: episode }
                    metrics: { views: 7, xp_earned: 210, time_spent_seconds: 900, is_completed: true, is_successful: false, questions_count: 12, quiz_average_score: 74.5, quiz_completion_rate: 66 }
                    cumulative: { views: 40, xp_earned: 1200, time_spent_seconds: 5400, quiz_average_score: 80 }
                meta: { per_page: 100, next_cursor: null }
        '400': { $ref: '#/components/responses/InvalidCursor' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/Forbidden' }
        '404': { $ref: '#/components/responses/NotFound' }
        '422': { $ref: '#/components/responses/ValidationOrRangeError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '503': { $ref: '#/components/responses/AnalyticsUnavailable' }

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: |
        A ToldUntold Client API key, presented as `Authorization: Bearer
        tuk_<key_id>.<secret>`. Legacy `cfk_` feed tokens are rejected here.

  parameters:
    From:
      name: from
      in: query
      required: false
      description: Inclusive lower day bound (`YYYY-MM-DD`, UTC). Defaults to the dataset's default window before `to`.
      schema: { type: string, format: date }
    To:
      name: to
      in: query
      required: false
      description: Inclusive upper day bound (`YYYY-MM-DD`, UTC). Defaults to yesterday (J-1).
      schema: { type: string, format: date }
    MarketFilter:
      name: market
      in: query
      required: false
      description: A market `tu_id`. Restricts `metrics/daily` to that market. Mutually exclusive with `group_by=market`. Unknown/cross-brand → 404.
      schema: { type: string }
    GroupByMarket:
      name: group_by
      in: query
      required: false
      description: Set to `market` for a per-market breakdown (one row per market per day). Mutually exclusive with `market`.
      schema: { type: string, enum: [market] }
    ContentTypeFilter:
      name: type
      in: query
      required: false
      description: Content-type filter. REQUIRED when `content` is supplied.
      schema: { type: string, enum: [episode, program] }
    ContentFilter:
      name: content
      in: query
      required: false
      description: A content `tu_id`. Requires `type`. Unknown/cross-brand → 404.
      schema: { type: string }
    UserFilter:
      name: user
      in: query
      required: false
      description: A user `tu_id`. Unknown/excluded/cross-brand → an indistinguishable 404.
      schema: { type: string }
    CompletedFilter:
      name: completed
      in: query
      required: false
      description: Filter `users/contents` rows by completion (applied store-side so pages stay full).
      schema: { type: boolean }
    Page:
      name: page
      in: query
      required: false
      description: 1-based page number (offset pagination). Clamped to >= 1.
      schema: { type: integer, minimum: 1, default: 1 }
    PerPageOffset:
      name: per_page
      in: query
      required: false
      description: Page size for offset pagination. Clamped to [1, 200].
      schema: { type: integer, minimum: 1, maximum: 200, default: 50 }
    PerPageCursor:
      name: per_page
      in: query
      required: false
      description: Page size for cursor pagination. Clamped to [1, 500].
      schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
    Cursor:
      name: cursor
      in: query
      required: false
      description: |
        Opaque, encrypted pagination cursor taken verbatim from a previous
        response's `meta.next_cursor`. Do not decode, construct or mutate it — a
        tampered or foreign cursor is rejected with `400 invalid_cursor`.
      schema: { type: string }

  headers:
    X-RateLimit-Limit:
      description: The request ceiling for the most-constrained bucket applying to this call.
      schema: { type: integer }
    X-RateLimit-Remaining:
      description: Requests remaining in the current window for that bucket.
      schema: { type: integer }
    X-RateLimit-Reset:
      description: Unix epoch (seconds) when the reported bucket resets.
      schema: { type: integer }
    Retry-After:
      description: Seconds to wait before retrying (present on 429 and 503).
      schema: { type: integer }

  responses:
    Unauthorized:
      description: |
        Missing, malformed, expired or revoked key — or a `cfk_` feed token
        presented on this surface.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          examples:
            missing: { value: { code: unauthenticated, message: Missing API key. } }
            invalid: { value: { code: unauthenticated, message: Invalid or expired API key. } }
            feed_token: { value: { code: unauthenticated, message: 'Feed tokens are not valid on the Client API. Use /v1/catalog/stock/feeds for stock ingestion, or issue a Client API key (tuk_).' } }
    Forbidden:
      description: The key is valid but lacks the required analytics scope.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: insufficient_scope, message: 'Missing scope: analytics.users:read.' }
    NotFound:
      description: The addressed market / content / user does not exist or belongs to another brand.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: not_found, message: Resource not found. }
    InvalidCursor:
      description: The `cursor` query parameter is malformed, tampered or unsupported.
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: invalid_cursor, message: The pagination cursor is malformed or unsupported. }
    ValidationOrRangeError:
      description: |
        Either standard field validation failed (`validation_failed`, carries an
        `errors` map) or the requested date window exceeds the dataset cap
        (`date_range_too_large`, no `errors` map — the message states the cap).
      content:
        application/json:
          schema:
            anyOf:
              - $ref: '#/components/schemas/ValidationError'
              - $ref: '#/components/schemas/Error'
          examples:
            validation_failed:
              value:
                code: validation_failed
                message: The given data was invalid.
                errors:
                  to: ['The to field must be a valid date in the format Y-m-d.']
            date_range_too_large:
              value:
                code: date_range_too_large
                message: The requested date range is too large; the maximum window is 92 days.
    RateLimited:
      description: |
        A per-key (120/min) or per-brand (240/min) rate limit was exceeded.
        Carries the `X-RateLimit-*` trio and `Retry-After`.
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
        X-RateLimit-Limit: { $ref: '#/components/headers/X-RateLimit-Limit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/X-RateLimit-Remaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/X-RateLimit-Reset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: rate_limited, message: Too many requests. }
    AnalyticsUnavailable:
      description: |
        The analytics backing store is temporarily unreachable. This is NEVER a
        silent empty page — retry after the hinted delay. The `status` endpoint
        does not emit this (it degrades to `datasets: null`).
      headers:
        Retry-After: { $ref: '#/components/headers/Retry-After' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example: { code: analytics_unavailable, message: Analytics store is temporarily unavailable. }

  schemas:
    # ── Errors ──
    Error:
      type: object
      description: Flat Client API error envelope.
      required: [code, message]
      properties:
        code:
          type: string
          description: Stable machine-readable error code.
        message:
          type: string
          description: Human-readable message (not for programmatic branching).
      additionalProperties: true
    ValidationError:
      type: object
      required: [code, message, errors]
      properties:
        code: { type: string, const: validation_failed }
        message: { type: string }
        errors:
          type: object
          description: Field → list of messages.
          additionalProperties:
            type: array
            items: { type: string }

    # ── Status ──
    KeyStatus:
      type: object
      required: [brand_id, key_id, name, scopes, expires_at, datasets]
      properties:
        brand_id: { type: integer }
        key_id: { type: string, description: Public key id (`tuk_<key_id>`). }
        name: { type: [string, 'null'] }
        scopes:
          type: array
          items: { type: string }
        expires_at: { type: [string, 'null'], format: date-time }
        datasets:
          description: Per-dataset freshness, or `null` when the store is unreachable.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/DatasetFreshness'
    DatasetFreshness:
      type: object
      required: [daily, contents, users, content_engagement]
      properties:
        daily: { $ref: '#/components/schemas/DatasetLatestDate' }
        contents: { $ref: '#/components/schemas/DatasetLatestDate' }
        users: { $ref: '#/components/schemas/DatasetLatestDate' }
        content_engagement: { $ref: '#/components/schemas/DatasetLatestDate' }
    DatasetLatestDate:
      type: object
      required: [latest_date]
      properties:
        latest_date:
          type: [string, 'null']
          format: date
          description: Latest ingested day (`YYYY-MM-DD`) for the brand, or `null` when no data exists yet.

    # ── Pagination meta ──
    OffsetMeta:
      type: object
      required: [current_page, per_page, total, last_page]
      properties:
        current_page: { type: integer }
        per_page: { type: integer }
        total: { type: integer }
        last_page: { type: integer }
    CursorMeta:
      type: object
      required: [per_page, next_cursor]
      properties:
        per_page: { type: integer }
        next_cursor:
          type: [string, 'null']
          description: Opaque encrypted cursor for the next page, or `null` on the last page. Pass it back verbatim as `cursor`.

    # ── Shared refs ──
    MarketRef:
      type: object
      required: [tu_id]
      properties:
        tu_id: { type: [string, 'null'] }
    UserRef:
      type: object
      required: [tu_id]
      properties:
        tu_id: { type: string, description: The ONLY user identifier exposed by the API (pseudonym). }
    ContentRef:
      type: object
      required: [tu_id, type, name]
      properties:
        tu_id: { type: string }
        type: { type: string, description: 'Open enum; currently `episode` or `program`.' }
        name: { type: [string, 'null'], description: Non-contractual display label (best-effort; may be null). }
    ContentRefLite:
      type: object
      required: [tu_id, type]
      properties:
        tu_id: { type: string }
        type: { type: string, description: 'Open enum; currently `episode` or `program`.' }

    # ── metrics/daily ──
    DailyMetricsList:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/DailyMetricRow' }
        meta: { $ref: '#/components/schemas/OffsetMeta' }
    DailyMetricRow:
      type: object
      required: [date, market, live_content, active_users, totals, episodes, programs, quests, overall_completion_rate, cumulative]
      properties:
        date: { type: string, format: date }
        market:
          description: '`null` for brand-level rows; else the market ref.'
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/MarketRef'
        live_content:
          type: object
          required: [episodes, programs, quests, products]
          properties:
            episodes: { type: integer }
            programs: { type: integer }
            quests: { type: integer }
            products: { type: integer }
        active_users: { type: integer }
        totals:
          type: object
          required: [views, completions, successes, xp_earned, time_spent_seconds]
          properties:
            views: { type: integer }
            completions: { type: integer }
            successes: { type: integer }
            xp_earned: { type: integer }
            time_spent_seconds: { type: integer }
        episodes: { $ref: '#/components/schemas/DailyTypeBlock' }
        programs: { $ref: '#/components/schemas/DailyTypeBlock' }
        quests: { $ref: '#/components/schemas/DailyQuestBlock' }
        overall_completion_rate:
          type: [number, 'null']
          description: Stored business metric, float 0–100. `null` when not computed for the day.
        cumulative:
          type: object
          required: [views, completions, successes, xp_earned]
          properties:
            views: { type: integer }
            completions: { type: integer }
            successes: { type: integer }
            xp_earned: { type: integer }
    DailyTypeBlock:
      type: object
      required: [views, completions, successes, time_spent_seconds, watch_rate, completion_rate, success_rate, quiz_average_score]
      properties:
        views: { type: integer }
        completions: { type: integer }
        successes: { type: integer }
        time_spent_seconds: { type: integer }
        watch_rate: { type: number, description: Float 0–100. }
        completion_rate: { type: number, description: Float 0–100. }
        success_rate: { type: number, description: Float 0–100. }
        quiz_average_score: { type: number, description: Float 0–100. }
    DailyQuestBlock:
      type: object
      required: [views, completions, successes, time_spent_seconds, watch_rate, completion_rate, success_rate, quiz_average_score]
      properties:
        views: { type: integer }
        completions: { type: integer }
        successes: { type: integer }
        time_spent_seconds: { type: integer }
        watch_rate: { type: number, description: Float 0–100. }
        completion_rate: { type: number, description: Float 0–100. }
        success_rate: { type: number, description: Float 0–100. }
        quiz_average_score:
          type: [number, 'null']
          description: Float 0–100. Nullable on the quests block.

    # ── metrics/contents ──
    ContentMetricsList:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/ContentMetricRow' }
        meta: { $ref: '#/components/schemas/CursorMeta' }
    ContentMetricRow:
      type: object
      required: [date, content, market, pos, metrics, cumulative]
      properties:
        date: { type: string, format: date }
        content:
          description: Resolved content identity, or `null` for an orphaned/cross-brand ref.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/ContentRef'
        market: { type: 'null', description: Always null on this dataset. }
        pos: { type: 'null', description: Always null on this dataset. }
        metrics:
          type: object
          required: [views, unique_views, completions, successes, active_users, total_users, engagement_rate, xp_earned, time_spent_seconds, questions_count, quiz_average_score]
          properties:
            views: { type: integer }
            unique_views: { type: integer }
            completions: { type: integer }
            successes: { type: integer }
            active_users: { type: integer }
            total_users: { type: integer }
            engagement_rate: { type: number, description: Float 0–100. }
            xp_earned: { type: integer, description: Daily XP increment (not the cumulative total). }
            time_spent_seconds: { type: integer }
            questions_count: { type: integer }
            quiz_average_score: { type: number, description: Float 0–100. }
        cumulative:
          type: object
          required: [views, completions, successes, watch_rate, completion_rate, success_rate, quiz_average_score]
          properties:
            views: { type: integer }
            completions: { type: integer }
            successes: { type: integer }
            watch_rate: { type: number, description: Float 0–100. }
            completion_rate: { type: number, description: Float 0–100. }
            success_rate: { type: number, description: Float 0–100. }
            quiz_average_score: { type: number, description: Float 0–100. }

    # ── users/daily ──
    UserDailyList:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/UserDailyRow' }
        meta: { $ref: '#/components/schemas/CursorMeta' }
    UserDailyRow:
      type: object
      required: [date, user, market, pos, metrics, cumulative]
      properties:
        date: { type: string, format: date }
        user: { $ref: '#/components/schemas/UserRef' }
        market: { type: 'null', description: Always null on this dataset. }
        pos: { type: 'null', description: Always null on this dataset. }
        metrics:
          type: object
          required: [views, completions, successes, xp_earned, time_spent_seconds, overall_score, episodes, programs, quests]
          properties:
            views: { type: integer }
            completions: { type: integer }
            successes: { type: integer }
            xp_earned: { type: integer }
            time_spent_seconds: { type: integer }
            overall_score: { type: number, description: Float 0–100. }
            episodes: { $ref: '#/components/schemas/UserTypeBlock' }
            programs: { $ref: '#/components/schemas/UserTypeBlock' }
            quests: { $ref: '#/components/schemas/UserTypeBlock' }
        cumulative:
          type: object
          required: [views, completions, successes, episodes, programs, quests]
          properties:
            views: { type: integer }
            completions: { type: integer }
            successes: { type: integer }
            episodes: { $ref: '#/components/schemas/UserTypeBlock' }
            programs: { $ref: '#/components/schemas/UserTypeBlock' }
            quests: { $ref: '#/components/schemas/UserTypeBlock' }
    UserTypeBlock:
      type: object
      required: [views, completions, successes]
      properties:
        views: { type: integer }
        completions: { type: integer }
        successes: { type: integer }

    # ── users/contents ──
    UserContentList:
      type: object
      required: [data, meta]
      properties:
        data:
          type: array
          items: { $ref: '#/components/schemas/UserContentRow' }
        meta: { $ref: '#/components/schemas/CursorMeta' }
    UserContentRow:
      type: object
      required: [date, user, content, metrics, cumulative]
      properties:
        date: { type: string, format: date }
        user: { $ref: '#/components/schemas/UserRef' }
        content:
          description: Resolved content identity (`{ tu_id, type }` only — no label), or `null` for an orphaned/cross-brand ref.
          oneOf:
            - type: 'null'
            - $ref: '#/components/schemas/ContentRefLite'
        metrics:
          type: object
          required: [views, xp_earned, time_spent_seconds, is_completed, is_successful, questions_count, quiz_average_score, quiz_completion_rate]
          properties:
            views: { type: integer }
            xp_earned: { type: integer }
            time_spent_seconds: { type: integer }
            is_completed: { type: boolean }
            is_successful: { type: boolean }
            questions_count: { type: integer }
            quiz_average_score: { type: number, description: Float 0–100. }
            quiz_completion_rate: { type: number, description: Float 0–100. }
        cumulative:
          type: object
          required: [views, xp_earned, time_spent_seconds, quiz_average_score]
          properties:
            views: { type: integer }
            xp_earned: { type: integer }
            time_spent_seconds: { type: integer }
            quiz_average_score: { type: number, description: Float 0–100. }
