openapi: 3.1.0
info:
  title: MessagePipe Analytics Service
  version: "1.0"
  description: >
    Read-only analytics for the customer dashboard (spec §C.10), backed by
    ClickHouse. Authenticated with the customer session cookie (`mp_session`);
    the caller must be an active member of the requested workspace.
    Endpoints: KPIs, funnel, heatmap, top-templates, geography,
    provider-comparison and export.
servers:
  - url: http://localhost:8082
    description: Local (internal service; customer analytics is proxied via mgmt-api)
security:
  - sessionCookie: []
tags:
  - { name: Analytics, description: Dashboard aggregations (§C.10.1) }
  - { name: Platform logs, description: Operator-only search over the platform event log (docs/platform-event-logger.md) }
  - { name: System, description: Health }
paths:
  /healthz:
    get:
      tags: [System]
      summary: Liveness / readiness (ClickHouse)
      security: []
      responses:
        "200": { description: OK, content: { application/json: { schema: { $ref: "#/components/schemas/Health" } } } }
        "503": { description: ClickHouse unavailable }
  /api/analytics/kpis:
    get:
      tags: [Analytics]
      summary: KPI tiles (sent, delivered, failed, delivery rate)
      parameters:
        - { $ref: "#/components/parameters/WorkspaceId" }
        - { $ref: "#/components/parameters/Range" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  total_sent: { type: integer }
                  delivered: { type: integer }
                  failed: { type: integer }
                  delivery_rate: { type: number, format: float }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/analytics/funnel:
    get:
      tags: [Analytics]
      summary: Channel funnel (event_type → count)
      parameters:
        - { $ref: "#/components/parameters/WorkspaceId" }
        - { name: channel, in: query, schema: { type: string, default: email } }
        - { $ref: "#/components/parameters/Range" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  channel: { type: string }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        event_type: { type: string }
                        total: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/analytics/heatmap:
    get:
      tags: [Analytics]
      summary: 7×24 send heatmap (fixed 7-day window)
      parameters:
        - { $ref: "#/components/parameters/WorkspaceId" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        day_of_week: { type: integer, minimum: 0, maximum: 6 }
                        hour: { type: integer, minimum: 0, maximum: 23 }
                        volume: { type: integer }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/analytics/geography:
    get:
      tags: [Analytics]
      summary: Send volume by recipient country
      parameters:
        - { $ref: "#/components/parameters/WorkspaceId" }
        - { $ref: "#/components/parameters/Range" }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        country_code: { type: string }
                        volume: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/analytics/provider-comparison:
    get:
      tags: [Analytics]
      summary: Per-provider delivery breakdown
      parameters:
        - { $ref: "#/components/parameters/WorkspaceId" }
        - { $ref: "#/components/parameters/Range" }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        provider_id: { type: string }
                        sent: { type: integer }
                        delivered: { type: integer }
                        failed: { type: integer }
                        delivery_rate: { type: number, format: float }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/analytics/top-templates:
    get:
      tags: [Analytics]
      summary: Most-used templates by send volume
      parameters:
        - { $ref: "#/components/parameters/WorkspaceId" }
        - { $ref: "#/components/parameters/Range" }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        template_id: { type: string }
                        total: { type: integer }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /api/analytics/export:
    get:
      tags: [Analytics]
      summary: Export analytics as CSV
      parameters:
        - { $ref: "#/components/parameters/WorkspaceId" }
        - { $ref: "#/components/parameters/Range" }
        - { $ref: "#/components/parameters/From" }
        - { $ref: "#/components/parameters/To" }
      responses:
        "200":
          description: CSV export
          content:
            text/csv:
              schema: { type: string, format: binary }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
  /internal/logs:
    get:
      tags: [Platform logs]
      summary: Search platform events (newest first)
      description: >
        Operator traffic only (admin-api proxies it as `/admin/logs`). Window defaults to the last hour and is
        at most 7 days. Keyset pagination: pass `next_cursor` back as `cursor`.
      security: [{ internalSecret: [] }]
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: kind, in: query, schema: { type: string, enum: [api_request, provider_call, webhook, app_log] } }
        - { name: service, in: query, schema: { type: string } }
        - { name: provider, in: query, schema: { type: string } }
        - { name: workspace_id, in: query, schema: { type: string } }
        - { name: request_id, in: query, schema: { type: string } }
        - { name: error_code, in: query, schema: { type: string } }
        - { name: level, in: query, description: Minimum level, schema: { type: string, enum: [debug, info, warn, error] } }
        - { name: status_class, in: query, schema: { type: string, enum: [2xx, 3xx, 4xx, 5xx] } }
        - { name: q, in: query, description: Case-insensitive substring of name or message, schema: { type: string, maxLength: 200 } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
        - { name: cursor, in: query, description: Opaque; from a previous response, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data, next_cursor]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/LogRow" } }
                  next_cursor: { type: [string, "null"] }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/NotConfigured" }
  /internal/logs/events/{id}:
    get:
      tags: [Platform logs]
      summary: One event, with request/response bodies when captured
      security: [{ internalSecret: [] }]
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { $ref: "#/components/schemas/LogEvent" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { description: Event not found, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
        "503": { $ref: "#/components/responses/NotConfigured" }
  /internal/logs/trace/{request_id}:
    get:
      tags: [Platform logs]
      summary: Every event of one request across services, oldest first (at most 1000)
      security: [{ internalSecret: [] }]
      parameters:
        - { name: request_id, in: path, required: true, schema: { type: string } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data: { type: array, items: { $ref: "#/components/schemas/LogRow" } }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/NotConfigured" }
  /internal/logs/summary:
    get:
      tags: [Platform logs]
      summary: Totals, per-kind counts, time series, latency, provider health and top errors
      security: [{ internalSecret: [] }]
      parameters:
        - { name: from, in: query, schema: { type: string, format: date-time } }
        - { name: to, in: query, schema: { type: string, format: date-time } }
        - { name: bucket, in: query, description: Default minute for windows up to 3h, else hour. Minute is limited to windows of 24h., schema: { type: string, enum: [minute, hour] } }
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema: { $ref: "#/components/schemas/LogSummary" }
        "400": { $ref: "#/components/responses/BadRequest" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "503": { $ref: "#/components/responses/NotConfigured" }
components:
  securitySchemes:
    sessionCookie:
      type: apiKey
      in: cookie
      name: mp_session
    internalSecret:
      type: apiKey
      in: header
      name: x-internal-secret
      description: INTERNAL_API_SECRET, compared in constant time. With no secret configured the routes answer 503.
  parameters:
    WorkspaceId:
      name: workspace_id
      in: query
      required: true
      schema: { type: string }
    Range:
      name: range
      in: query
      required: false
      schema: { type: string, enum: [24h, 7d, 30d, 90d, custom], default: 24h }
      description: "Defaults to the last 24h when omitted. Use custom with the from/to parameters for an arbitrary window."
    From:
      name: from
      in: query
      required: false
      schema: { type: string, format: date-time }
      description: "RFC3339 start of the window (required when range=custom)."
    To:
      name: to
      in: query
      required: false
      schema: { type: string, format: date-time }
      description: "RFC3339 end of the window (required when range=custom)."
  schemas:
    LogRow:
      type: object
      required: [id, occurred_at, kind, service, level, has_body]
      properties:
        id: { type: string }
        occurred_at: { type: string, format: date-time }
        kind: { type: string, enum: [api_request, provider_call, webhook, app_log] }
        direction: { type: string, enum: [inbound, outbound, internal] }
        service: { type: string }
        level: { type: string, enum: [debug, info, warn, error] }
        request_id: { type: string }
        trace_id: { type: string }
        workspace_id: { type: string }
        project_id: { type: string }
        api_key_id: { type: string }
        actor_id: { type: string }
        name: { type: string }
        method: { type: string }
        status: { type: integer }
        duration_ms: { type: integer }
        error_code: { type: string }
        provider: { type: string }
        provider_config_id: { type: string }
        bytes_in: { type: integer }
        bytes_out: { type: integer }
        message: { type: string }
        attrs: { type: object, additionalProperties: { type: string } }
        truncated: { type: boolean }
        has_body: { type: boolean }
    LogEvent:
      allOf:
        - { $ref: "#/components/schemas/LogRow" }
        - type: object
          properties:
            body_in: { type: string }
            body_out: { type: string }
    LogSummary:
      type: object
      properties:
        window:
          type: object
          properties:
            from: { type: string, format: date-time }
            to: { type: string, format: date-time }
            bucket: { type: string, enum: [minute, hour] }
        totals:
          type: object
          properties: { events: { type: integer }, warnings: { type: integer }, errors: { type: integer } }
        by_kind:
          type: array
          items: { type: object, properties: { kind: { type: string }, count: { type: integer }, errors: { type: integer } } }
        timeseries:
          type: array
          items: { type: object, properties: { t: { type: string, format: date-time }, kind: { type: string }, count: { type: integer }, errors: { type: integer } } }
        latency:
          type: array
          description: Top 25 by count, excluding app_log
          items:
            type: object
            properties:
              service: { type: string }
              name: { type: string }
              count: { type: integer }
              p50_ms: { type: number }
              p95_ms: { type: number }
              p99_ms: { type: number }
              error_rate: { type: number }
        providers:
          type: array
          items:
            type: object
            properties:
              provider: { type: string }
              calls: { type: integer }
              failures: { type: integer }
              error_rate: { type: number }
              p95_ms: { type: number }
              top_error_codes:
                type: array
                items: { type: object, properties: { code: { type: string }, count: { type: integer } } }
        top_errors:
          type: array
          description: Top 20
          items:
            type: object
            properties:
              service: { type: string }
              name: { type: string }
              error_code: { type: string }
              count: { type: integer }
              last_seen: { type: string, format: date-time }
    Health:
      type: object
      properties: { status: { type: string, example: ok } }
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code: { type: string }
            message: { type: string }
  responses:
    NotConfigured: { description: INTERNAL_API_SECRET is not configured, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    BadRequest: { description: "validation_failed", content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    Unauthorized: { description: Not authenticated, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
    NotFound: { description: Workspace not found / not a member, content: { application/json: { schema: { $ref: "#/components/schemas/Error" } } } }
