# OpenAPI 3.1 contract for the MakoSwim Federation Data API v1.
# Human companion: docs/DATA_API.md. Additive changes are non-breaking.
openapi: 3.1.0
info:
  title: MakoSwim Federation Data API
  version: "1.0.0"
  description: >
    Read-only, key-authenticated data exports for federation sport-science
    staff. Org-scoped; tenancy is derived from the API key. Errors are RFC 9457
    problem+json. All list responses share the cursor envelope.
servers:
  - url: https://dataapi-placeholder.a.run.app
    description: europe-west3 (Frankfurt) — actual URL issued with credentials
security:
  - apiKey: []
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: "Authorization: Bearer mk_live_…"
  parameters:
    from: { name: from, in: query, schema: { type: string, format: date }, description: Inclusive start date (YYYY-MM-DD) }
    to: { name: to, in: query, schema: { type: string, format: date }, description: Inclusive end date (YYYY-MM-DD) }
    teamId: { name: teamId, in: query, schema: { type: string } }
    athleteId: { name: athleteId, in: query, schema: { type: string } }
    limit: { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 100 }, description: Counts SOURCE documents; exploding endpoints may return more rows. Values above 500 are clamped. }
    cursor: { name: cursor, in: query, schema: { type: string }, description: Opaque continuation token from next_cursor. Keep all other params identical across pages. }
  schemas:
    Problem:
      type: object
      properties:
        type: { type: string, format: uri }
        title: { type: string }
        status: { type: integer }
        detail: { type: string }
        code: { type: string, enum: [unauthorized, forbidden_federation, forbidden_scope, not_found, method_not_allowed, invalid_param, invalid_cursor, rate_limited, internal] }
        param: { type: string }
    Envelope:
      type: object
      required: [data, has_more, next_cursor]
      properties:
        data: { type: array, items: { type: object } }
        has_more: { type: boolean }
        next_cursor: { type: [string, "null"] }
    Athlete:
      type: object
      properties:
        id: { type: string }
        firstName: { type: [string, "null"] }
        lastName: { type: [string, "null"] }
        gender: { type: [string, "null"], enum: [M, F, null] }
        dateOfBirth: { type: [string, "null"], format: date }
        mainStroke: { type: [string, "null"] }
        teamId: { type: [string, "null"] }
        trainingGroupId: { type: [string, "null"] }
        status: { type: [string, "null"] }
        hasAccount: { type: boolean }
    Team:
      type: object
      properties:
        id: { type: string }
        name: { type: [string, "null"] }
        description: { type: [string, "null"] }
        siteId: { type: [string, "null"] }
        siteName: { type: [string, "null"] }
        trainingGroups:
          type: array
          items: { type: object, properties: { id: { type: [string, "null"] }, name: { type: [string, "null"] }, color: { type: [string, "null"] } } }
    WorkoutRow:
      type: object
      description: One row per athlete per workout (save-time aggregates).
      properties:
        workoutId: { type: string }
        date: { type: [string, "null"], format: date }
        session: { type: [string, "null"] }
        sessionIndex: { type: [integer, "null"] }
        teamId: { type: [string, "null"] }
        poolType: { type: [string, "null"], enum: [SCY, SCM, LCM, null] }
        athleteId: { type: string }
        totalVolume: { type: number }
        trainingLoad: { type: [number, "null"] }
        byEnergy: { type: object, additionalProperties: { type: number } }
        byStroke: { type: object, additionalProperties: { type: number } }
        byTrainingStyle: { type: object, additionalProperties: { type: number } }
        byEquipment: { type: object, additionalProperties: { type: number } }
        estimatedDurationSeconds: { type: [number, "null"] }
    SegmentRow:
      type: object
      description: Joint attribution — one row per athlete × workout × segment.
      properties:
        workoutId: { type: string }
        date: { type: [string, "null"], format: date }
        session: { type: [string, "null"] }
        teamId: { type: [string, "null"] }
        athleteId: { type: string }
        volume: { type: number }
        zone: { type: [string, "null"] }
        stroke: { type: [string, "null"] }
        style: { type: [string, "null"] }
        equipment: { type: array, items: { type: string } }
    AttendanceRow:
      type: object
      properties:
        attendanceId: { type: string }
        teamId: { type: [string, "null"] }
        date: { type: [string, "null"], format: date }
        session: { type: [string, "null"] }
        sessionIndex: { type: [integer, "null"] }
        workoutId: { type: [string, "null"] }
        athleteId: { type: string }
        status: { type: [string, "null"], enum: [present, late, absent, excused, planned_absence, made_up, null] }
        trainingGroupId: { type: [string, "null"] }
        notes: { type: [string, "null"] }
        madeUpOn: { type: [string, "null"], format: date }
        isGuest: { type: boolean }
    Performance:
      type: object
      description: Race-model swim. type=training is a timed race-style swim in training (distinct from Metrics).
      properties:
        id: { type: string }
        athleteId: { type: [string, "null"] }
        teamId: { type: [string, "null"] }
        date: { type: [string, "null"], format: date }
        type: { type: [string, "null"], enum: [competition, training, null] }
        event: { type: [string, "null"] }
        stroke: { type: [string, "null"] }
        poolType: { type: [string, "null"] }
        totalTimeSeconds: { type: [number, "null"] }
        reactionTimeSeconds: { type: [number, "null"] }
        startType: { type: [string, "null"] }
        suitType: { type: [string, "null"] }
        aquaPoints: { type: [number, "null"] }
        competitionId: { type: [string, "null"] }
        competitionName: { type: [string, "null"] }
        isManualEntry: { type: boolean }
        taggedTimes: { type: object, additionalProperties: { type: number }, description: Cumulative seconds at tagged marks ("15m", "50m", …) }
        strokeCounts: { type: object }
        strokeRates: { type: object, additionalProperties: { type: number }, description: SPM at measurement points }
    Metric:
      type: object
      properties:
        id: { type: string }
        name: { type: [string, "null"] }
        description: { type: [string, "null"] }
        type: { type: [string, "null"], enum: [time, count, null] }
        unit: { type: [string, "null"] }
        distance: { type: [number, "null"] }
        poolType: { type: [string, "null"] }
        scope: { type: [string, "null"], enum: [account, team, null] }
        teamId: { type: [string, "null"] }
        isActive: { type: boolean }
        isComposite: { type: boolean }
        compositeStructure: { type: [object, "null"] }
    MetricResult:
      type: object
      properties:
        id: { type: string }
        athleteId: { type: [string, "null"] }
        teamId: { type: [string, "null"] }
        metricId: { type: [string, "null"] }
        metricName: { type: [string, "null"] }
        date: { type: [string, "null"], format: date }
        session: { type: [string, "null"] }
        workoutId: { type: [string, "null"] }
        value: { type: [number, "null"] }
        displayValue: { type: [string, "null"] }
        type: { type: [string, "null"] }
        unit: { type: [string, "null"] }
        poolType: { type: [string, "null"] }
        stroke: { type: [string, "null"] }
        suit: { type: [string, "null"] }
        notes: { type: [string, "null"] }
        isComposite: { type: boolean }
        compositeStructure: { type: [object, "null"] }
        repValues: { type: array, items: { type: number } }
        repSplitTimes: { type: object, additionalProperties: { type: array, items: { type: number } } }
        totalDistance: { type: [number, "null"] }
        createdAt: { type: [string, "null"], format: date-time }
    WellnessRow:
      type: object
      description: Name-free by design; requires the wellness scope. Excluded athletes are absent.
      properties:
        id: { type: string }
        athleteId: { type: [string, "null"] }
        teamId: { type: [string, "null"] }
        date: { type: [string, "null"], format: date }
        energy: { type: [number, "null"] }
        sleepHours: { type: [number, "null"] }
        sleepQuality: { type: [number, "null"] }
        muscleSoreness: { type: [number, "null"] }
        lifeStress: { type: [number, "null"] }
        willingnessToTrain: { type: [number, "null"] }
        wellnessScore: { type: [number, "null"] }
        moodQuadrant: { type: [string, "null"] }
        submittedAt: { type: [string, "null"], format: date-time }
    StructuredLine:
      type: object
      description: >
        Machine-readable structure for one content line, produced by the same
        parsing engine the app uses. Accuracy contract: every field is
        engine-produced or consistency-proven (reps × distance must equal the
        engine volume exactly); unprovable fields are null, never guessed.
        `segments` are always the authoritative decomposition.
      properties:
        text: { type: string, description: The line byte-verbatim (leading whitespace = nesting) }
        indent: { type: integer }
        volume: { type: number, description: Engine-computed meters for this line }
        reps: { type: [integer, "null"] }
        distance: { type: [number, "null"] }
        intervals: { type: array, items: { type: object, properties: { pattern: { type: string }, seconds: { type: [number, "null"] } } } }
        intervalSeconds: { type: [number, "null"], description: Set only when the line has exactly one interval }
        segments: { type: array, items: { type: object, properties: { volume: { type: number }, zone: { type: [string, "null"] }, stroke: { type: [string, "null"] }, style: { type: [string, "null"] }, equipment: { type: array, items: { type: string } } } } }
        skillCounts: { type: array, items: { type: object, properties: { skillId: { type: [string, "null"] }, term: { type: [string, "null"] }, count: { type: [number, "null"] }, rest: { type: [string, "null"] } } } }
        skillVolume: { type: [number, "null"], description: Engine skill meters (count × distancePerRep) — skill work is not swim volume, matching the app }
        notes: { type: [string, "null"] }
        flags: { type: object, properties: { postRound: { type: boolean, description: Executes once after all rounds }, skillLine: { type: boolean }, restSeconds: { type: [number, "null"] } } }
    WorkoutContent:
      type: object
      description: One row per workout — the prescription as written. Set `content` is byte-verbatim.
      properties:
        workoutId: { type: string }
        date: { type: [string, "null"], format: date }
        session: { type: [string, "null"] }
        sessionIndex: { type: [integer, "null"] }
        teamId: { type: [string, "null"] }
        poolType: { type: [string, "null"] }
        name: { type: [string, "null"] }
        workoutType: { type: [string, "null"] }
        workoutTypeName: { type: [string, "null"] }
        isLive: { type: boolean }
        seasonCycleId: { type: [string, "null"] }
        weekNumber: { type: [integer, "null"] }
        trainingGroups: { type: array, items: { type: string } }
        sets:
          type: array
          items:
            type: object
            properties:
              id: { type: [string, "null"] }
              order: { type: [integer, "null"] }
              type: { type: [string, "null"] }
              rounds: { type: integer }
              groups: { type: array, items: { type: string } }
              athletes: { type: array, items: { type: string } }
              content: { type: string, description: Byte-verbatim set text — never normalize whitespace }
              structured: { type: [array, "null"], items: { $ref: "#/components/schemas/StructuredLine" }, description: Present only with structured=true; null when the team has no terminology config }
        messages: { type: array, items: { type: object } }
        roster:
          type: object
          properties:
            useExplicitRoster: { type: boolean }
            assignedAthleteIds: { type: array, items: { type: string } }
            guestAthleteIds: { type: array, items: { type: string } }
            flexRoster: { type: object, additionalProperties: { type: object, properties: { assignedGroup: { type: [string, "null"] }, originalGroup: { type: [string, "null"] } } } }
            strokeFlexAssignments: { type: object, additionalProperties: { type: object, additionalProperties: { type: object, properties: { strokeOverride: {} } } } }
    SetVolumeRow:
      type: object
      description: Per-athlete per-set actuals (join setId to /v1/workout-content sets)
      properties:
        workoutId: { type: string }
        setId: { type: string }
        date: { type: [string, "null"], format: date }
        session: { type: [string, "null"] }
        teamId: { type: [string, "null"] }
        athleteId: { type: string }
        totalVolume: { type: number }
        trainingLoad: { type: [number, "null"] }
        byEnergy: { type: object, additionalProperties: { type: number } }
        byStroke: { type: object, additionalProperties: { type: number } }
        byTrainingStyle: { type: object, additionalProperties: { type: number } }
        byEquipment: { type: object, additionalProperties: { type: number } }
    Terminology:
      type: object
      description: The organization's terminology config — the legend for content text and segment ids
      properties:
        id: { type: string }
        name: { type: [string, "null"] }
        teamId: { type: [string, "null"] }
        version: { type: [string, "null"] }
        defaultEnergyZone: { type: [string, "null"] }
        energySystems: { type: array, items: { type: object, properties: { id: { type: [string, "null"] }, term: { type: [string, "null"] }, fullName: { type: [string, "null"] }, color: { type: [string, "null"] }, rpeMin: { type: [number, "null"] }, rpeMax: { type: [number, "null"] }, aliases: { type: array, items: { type: string } } } } }
        strokes: { type: array, items: { type: object } }
        styles: { type: array, items: { type: object } }
        skills: { type: array, items: { type: object } }
        workoutTypes: { type: array, items: { type: object } }
  responses:
    Err400: { description: Invalid parameter or cursor, content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    Err401: { description: Missing/unknown/revoked key, content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    Err403: { description: Missing scope or not a federation org, content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
    Err429: { description: Rate limited (respect Retry-After), content: { application/problem+json: { schema: { $ref: "#/components/schemas/Problem" } } } }
paths:
  /v1/athletes:
    get:
      summary: Roster athletes (no contact details)
      parameters: [{ $ref: "#/components/parameters/teamId" }, { $ref: "#/components/parameters/limit" }, { $ref: "#/components/parameters/cursor" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/Athlete" } } } }] } } } }
        "400": { $ref: "#/components/responses/Err400" }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
        "429": { $ref: "#/components/responses/Err429" }
  /v1/teams:
    get:
      summary: Teams with sites and training groups
      parameters: [{ $ref: "#/components/parameters/limit" }, { $ref: "#/components/parameters/cursor" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/Team" } } } }] } } } }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/workouts:
    get:
      summary: Per-athlete workout aggregates (volume, load, marginal breakdowns)
      parameters: [{ $ref: "#/components/parameters/from" }, { $ref: "#/components/parameters/to" }, { $ref: "#/components/parameters/teamId" }, { $ref: "#/components/parameters/athleteId" }, { $ref: "#/components/parameters/limit" }, { $ref: "#/components/parameters/cursor" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/WorkoutRow" } } } }] } } } }
        "400": { $ref: "#/components/responses/Err400" }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/workout-segments:
    get:
      summary: Joint attribution rows (volume × zone × stroke × style × equipment)
      parameters:
        - { $ref: "#/components/parameters/from" }
        - { $ref: "#/components/parameters/to" }
        - { $ref: "#/components/parameters/teamId" }
        - { $ref: "#/components/parameters/athleteId" }
        - { name: medley, in: query, schema: { type: string, enum: [resolved, preserved], default: resolved } }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/SegmentRow" } } } }] } } } }
        "400": { $ref: "#/components/responses/Err400" }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/workout-content:
    get:
      summary: Workout prescriptions (verbatim set text; optional engine-parsed structure)
      parameters:
        - { $ref: "#/components/parameters/from" }
        - { $ref: "#/components/parameters/to" }
        - { $ref: "#/components/parameters/teamId" }
        - { name: structured, in: query, schema: { type: string, enum: ["true", "false"], default: "false" }, description: Parse each set's content with the production engine against the team's own terminology }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/WorkoutContent" } } } }] } } } }
        "400": { $ref: "#/components/responses/Err400" }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/workout-set-volumes:
    get:
      summary: Per-athlete per-set actuals (join to workout-content sets via setId)
      parameters: [{ $ref: "#/components/parameters/from" }, { $ref: "#/components/parameters/to" }, { $ref: "#/components/parameters/teamId" }, { $ref: "#/components/parameters/athleteId" }, { $ref: "#/components/parameters/limit" }, { $ref: "#/components/parameters/cursor" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/SetVolumeRow" } } } }] } } } }
        "400": { $ref: "#/components/responses/Err400" }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/terminology:
    get:
      summary: Terminology configs (the legend for content and segment ids)
      parameters: [{ $ref: "#/components/parameters/limit" }, { $ref: "#/components/parameters/cursor" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/Terminology" } } } }] } } } }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/attendance:
    get:
      summary: Attendance rows (one per athlete per session)
      parameters: [{ $ref: "#/components/parameters/from" }, { $ref: "#/components/parameters/to" }, { $ref: "#/components/parameters/teamId" }, { $ref: "#/components/parameters/athleteId" }, { $ref: "#/components/parameters/limit" }, { $ref: "#/components/parameters/cursor" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/AttendanceRow" } } } }] } } } }
        "400": { $ref: "#/components/responses/Err400" }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/performances:
    get:
      summary: Race-model performances (competition and training types)
      parameters:
        - { $ref: "#/components/parameters/from" }
        - { $ref: "#/components/parameters/to" }
        - { $ref: "#/components/parameters/teamId" }
        - { $ref: "#/components/parameters/athleteId" }
        - { name: type, in: query, schema: { type: string, enum: [competition, training] } }
        - { $ref: "#/components/parameters/limit" }
        - { $ref: "#/components/parameters/cursor" }
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/Performance" } } } }] } } } }
        "400": { $ref: "#/components/responses/Err400" }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/metrics:
    get:
      summary: MakoBoards metric definitions catalog
      parameters: [{ $ref: "#/components/parameters/limit" }, { $ref: "#/components/parameters/cursor" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/Metric" } } } }] } } } }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/metric-results:
    get:
      summary: MakoBoards results (incl. composite per-rep values/splits)
      parameters: [{ $ref: "#/components/parameters/from" }, { $ref: "#/components/parameters/to" }, { $ref: "#/components/parameters/teamId" }, { $ref: "#/components/parameters/athleteId" }, { name: metricId, in: query, schema: { type: string } }, { $ref: "#/components/parameters/limit" }, { $ref: "#/components/parameters/cursor" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/MetricResult" } } } }] } } } }
        "400": { $ref: "#/components/responses/Err400" }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
  /v1/wellness:
    get:
      summary: Wellness survey rows (wellness scope; name-free; exclusions honored)
      parameters: [{ $ref: "#/components/parameters/from" }, { $ref: "#/components/parameters/to" }, { $ref: "#/components/parameters/teamId" }, { $ref: "#/components/parameters/athleteId" }, { $ref: "#/components/parameters/limit" }, { $ref: "#/components/parameters/cursor" }]
      responses:
        "200": { description: OK, content: { application/json: { schema: { allOf: [{ $ref: "#/components/schemas/Envelope" }, { properties: { data: { type: array, items: { $ref: "#/components/schemas/WellnessRow" } } } }] } } } }
        "400": { $ref: "#/components/responses/Err400" }
        "401": { $ref: "#/components/responses/Err401" }
        "403": { $ref: "#/components/responses/Err403" }
