openapi: "3.1.0"

info:
  title: Handicap Intelligence Engine
  version: "3.9.0"
  description: |
    API-first, headless Golf Handicap calculation and intelligence system.

    All calculations are based on the **World Handicap System (WHS)** and are:
    - **Deterministic** — same inputs always produce the same outputs
    - **Explainable** — every result carries a full audit trail
    - **Actionable** — Insights and Recommendations tell you what to do, not just what happened

    ## Authentication
    All endpoints except `/auth/register`, `/auth/login`, `/auth/verify-email`,
    `/auth/forgot-password`, and `/auth/reset-password` require a **Bearer JWT token**
    in the `Authorization` header.

    After registering or logging in, include the token as:
    ```
    Authorization: Bearer <token>
    ```

    ## Product Layers

    | Layer | Endpoints | Purpose |
    |-------|-----------|---------|
    | **Identity** | `/auth/*`, `/me` | Auth and player state |
    | **Core** | `/rounds`, `/players/{id}/handicap` | WHS calculation |
    | **Simulation** | `/players/{id}/simulate*` | What-if scenarios |
    | **Analysis** | `/players/{id}/impact`, `/players/{id}/trends` | Performance signals |
    | **Intelligence** | `/players/{id}/insights`, `/players/{id}/recommendations` | Interpretation + action |
    | **Context** | `/players/{id}/context` | Full player dashboard |

servers:
  - url: https://handicap-engine.de/api/v1
    description: Production — handicap-engine.de

security:
  - bearerAuth: []

components:

  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT

  schemas:


    CourseOutput:
      type: object
      required: [courseId, name, country, status, teeSets, externalIdentities]
      properties:
        courseId:
          type: string
          format: uuid
          description: Unique course identifier. Use this as `courseId` when posting a round.
          example: "11111111-0001-0000-0000-000000000001"
        name:
          type: string
          description: Full course name as printed on the scorecard.
          example: "Hamburger Golf-Club e.V. (Falkenstein)"
        country:
          type: string
          example: "DE"
          description: ISO 3166-1 alpha-2 country code.
        clubName:
          type: string
          nullable: true
          example: "Hamburger Golf-Club e.V."
          description: Name of the organizing golf club. Null if not provided.
        status:
          type: string
          enum: [ACTIVE, INACTIVE, ARCHIVED, DRAFT]
          description: Lifecycle status. Only ACTIVE courses are returned in public search results.
          example: "ACTIVE"
        city:
          type: string
          nullable: true
          example: "Hamburg"
          description: City where the course is located. Null if not provided.
        state:
          type: string
          nullable: true
          example: "Hamburg"
          description: Federal state or region. Null if not provided.
        address:
          type: string
          nullable: true
          example: "In de Bargen 59"
          description: Street address. Null if not provided.
        latitude:
          type: number
          format: float
          nullable: true
          example: 53.5753
          description: WGS84 latitude. Null if not geocoded.
        longitude:
          type: number
          format: float
          nullable: true
          example: 9.8606
          description: WGS84 longitude. Null if not geocoded.
        teeSets:
          type: array
          description: |
            Canonical tee data. Each TeeSet represents the physical abschlag
            (e.g. "Gelb") and contains one TeeRating per gender/routing combination.
            Use `teeRatingId` from a rating when posting a round (preferred).
          items:
            $ref: '#/components/schemas/TeeSetOutput'
        externalIdentities:
          type: array
          description: Provider external IDs for this course (e.g. GolfCourseAPI id).
          items:
            type: object
            required: [provider, externalId]
            properties:
              provider:
                type: string
                example: "golfcourseapi"
              externalId:
                type: string
                example: "1234"

    TeeSetOutput:
      type: object
      required: [teeSetId, name, ratings]
      properties:
        teeSetId:
          type: string
          format: uuid
          example: "a1b2c3d4-0001-0000-0000-000000000001"
        name:
          type: string
          example: "Gelb"
          description: Physical tee marker color/name (e.g. "Gelb", "Weiß", "Rot", "Schwarz").
        ratings:
          type: array
          items:
            $ref: '#/components/schemas/TeeRatingOutput'

    HoleOutput:
      type: object
      required: [holeNumber, par]
      properties:
        holeNumber:
          type: integer
          minimum: 1
          maximum: 18
          description: Hole number (1–18 for full course, 1–9 for 9-hole routes).
          example: 1
        par:
          type: integer
          enum: [3, 4, 5]
          description: Par value for this hole (3, 4, or 5).
          example: 4
        strokeIndex:
          type: integer
          minimum: 1
          maximum: 18
          nullable: true
          description: WHS stroke index (difficulty rank) for this hole. Required for NDB capping in hole-by-hole input mode. Null if not yet rated.
          example: 7
        yardage:
          type: integer
          nullable: true
          description: Hole length in yards (or meters — see `totalDistanceUnit` on the TeeRating). Null if not provided.
          example: 380

    TeeRatingOutput:
      type: object
      required: [teeRatingId, ratingCategory, holeCount, routingType, courseRating, slopeRating, par, isNineHoleTee, suggestedRoundType, hasHoleData, validFrom, holes]
      properties:
        teeRatingId:
          type: string
          format: uuid
          example: "b2c3d4e5-0001-0000-0000-000000000001"
          description: Use this ID in `POST /v1/rounds` as `teeRatingId` (Mode A preferred path).
        ratingCategory:
          type: string
          enum: [MEN, WOMEN, UNSPECIFIED]
          example: "MEN"
          description: WHS rating applicability category.
        holeCount:
          type: integer
          enum: [9, 18]
          example: 18
        routingType:
          type: string
          enum: [FULL_18, FRONT_NINE, BACK_NINE, COMPOSITE]
          example: "FULL_18"
        courseRating:
          type: number
          format: float
          example: 71.8
        slopeRating:
          type: integer
          example: 135
        par:
          type: integer
          example: 72
        isNineHoleTee:
          type: boolean
          description: True for 9-hole routes (FRONT_NINE / BACK_NINE). Drives the UI round-type selector.
        suggestedRoundType:
          type: string
          enum: [18hole, 9front, 9back]
          example: "18hole"
          description: Always read from this field — never compute from CR ranges.
        hasHoleData:
          type: boolean
          description: |
            True when stroke-index data for individual holes is available.
            Only courses imported via GolfCourseAPI carry per-hole data.
            Use this flag to enable/disable the hole-by-hole score entry mode in the client.
        bogeyRating:
          type: number
          format: float
          nullable: true
          example: 94.5
          description: WHS Bogey Rating. Available for GolfCourseAPI imports and B2B entries.
        totalDistance:
          type: integer
          nullable: true
          example: 6540
          description: Total course length. Unit is given by `totalDistanceUnit`.
        totalDistanceUnit:
          type: string
          nullable: true
          enum: [YARDS, METERS]
          example: "YARDS"
          description: Unit for `totalDistance`. Always present when `totalDistance` is set; null otherwise.
        ratingAuthority:
          type: string
          nullable: true
          example: "DGV"
          description: Authority that issued the rating (e.g. DGV, USGA, R&A).
        ratedOn:
          type: string
          format: date
          nullable: true
          example: "2023-05-15"
          description: Date the official rating was issued.
        frontCourseRating:
          type: number
          format: float
          nullable: true
          example: 35.8
          description: Course Rating for the front 9 (within a full 18-hole rating).
        frontSlopeRating:
          type: integer
          nullable: true
          example: 134
          description: Slope Rating for the front 9.
        frontBogeyRating:
          type: number
          format: float
          nullable: true
          example: null
          description: Bogey Rating for the front 9. Available for GolfCourseAPI imports and B2B entries.
        backCourseRating:
          type: number
          format: float
          nullable: true
          example: 35.6
          description: Course Rating for the back 9 (within a full 18-hole rating).
        backSlopeRating:
          type: integer
          nullable: true
          example: 136
          description: Slope Rating for the back 9.
        backBogeyRating:
          type: number
          format: float
          nullable: true
          example: null
          description: Bogey Rating for the back 9. Available for GolfCourseAPI imports and B2B entries.
        validFrom:
          type: string
          format: date
          example: "1970-01-01"
          description: Effective-from date for this rating version. Default 1970-01-01 (always active).
        validTo:
          type: string
          format: date
          nullable: true
          example: null
          description: Effective-until date. Null = currently active.
        holes:
          type: array
          description: Per-hole data when available. Empty array when hasHoleData is false.
          items:
            $ref: '#/components/schemas/HoleOutput'

    CourseUpdateRequest:
      type: object
      required: [name]
      description: Updates mutable course metadata. All fields except name are optional.
      properties:
        name:
          type: string
          example: "Hamburger Golf-Club Falkenstein"
        clubName:
          type: string
          nullable: true
          example: "Hamburger Golf-Club e.V."
        country:
          type: string
          example: "DE"
          description: ISO 3166-1 alpha-2 country code.
        city:
          type: string
          nullable: true
          example: "Hamburg"
        state:
          type: string
          nullable: true
          example: "Hamburg"
        address:
          type: string
          nullable: true
          example: "In de Bargen 59"
        latitude:
          type: number
          format: float
          nullable: true
          example: 53.5753
        longitude:
          type: number
          format: float
          nullable: true
          example: 9.8606

    SetHolesRequest:
      type: object
      required: [holes]
      description: Bulk-replaces all hole definitions for a TeeRating. Idempotent.
      properties:
        holes:
          type: array
          minItems: 9
          maxItems: 18
          items:
            type: object
            required: [holeNumber, par]
            properties:
              holeNumber:
                type: integer
                minimum: 1
                maximum: 18
                example: 1
              par:
                type: integer
                enum: [3, 4, 5]
                example: 4
              strokeIndex:
                type: integer
                minimum: 1
                maximum: 18
                nullable: true
                example: 7
              yardage:
                type: integer
                nullable: true
                example: 380

    SubmitProposalRequest:
      type: object
      required: [proposalType, payload]
      properties:
        proposalType:
          type: string
          enum: [COURSE_METADATA, TEE_RATING, HOLE_DATA]
          example: "COURSE_METADATA"
        teeRatingId:
          type: string
          format: uuid
          nullable: true
          description: Required when proposalType is TEE_RATING or HOLE_DATA.
        payload:
          type: object
          description: |
            COURSE_METADATA payload keys: name, country, clubName (all optional).
            TEE_RATING payload keys: courseRating (float), slopeRating (int), par (int).
            HOLE_DATA payload: { holes: [{ holeNumber, par, strokeIndex?, distance?, distanceUnit? }] }.
          additionalProperties: true
          example:
            name: "Hamburger Golf-Club e.V. (korrigiert)"

    CourseDataProposalOutput:
      type: object
      properties:
        proposalId:
          type: string
          format: uuid
          description: Unique proposal identifier.
        courseId:
          type: string
          format: uuid
          description: Course this proposal targets.
        teeRatingId:
          type: string
          format: uuid
          nullable: true
          description: Specific TeeRating targeted by this proposal. Null for COURSE_METADATA proposals.
        proposedByUserId:
          type: string
          description: Player ID of the user who submitted the proposal.
        proposalType:
          type: string
          enum: [COURSE_METADATA, TEE_RATING, HOLE_DATA]
          description: What kind of data is being proposed — course metadata, tee rating corrections, or hole data.
        payload:
          type: object
          additionalProperties: true
          description: The proposed data. Shape depends on proposalType (see SubmitProposalRequest).
        status:
          type: string
          enum: [PENDING, APPROVED, REJECTED]
          description: Current review status of the proposal.
        submittedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp when the proposal was submitted.
        reviewedAt:
          type: string
          format: date-time
          nullable: true
          description: ISO 8601 timestamp when an admin reviewed the proposal. Null if still pending.
        reviewNote:
          type: string
          nullable: true
          description: Optional note from the reviewing admin. Null if not set.

    CreateCourseRequest:
      type: object
      required: [name, teeSets]
      description: |
        Creates a new course with one or more TeeSets.

        **Minimum viable payload** (one TeeSet, one rating):
        ```json
        {
          "name": "Mein Heimplatz",
          "teeSets": [{
            "name": "Gelb",
            "ratings": [{
              "ratingCategory": "MEN",
              "routingType": "FULL_18",
              "courseRating": 71.5,
              "slopeRating": 126,
              "par": 72
            }]
          }]
        }
        ```

        **Full B2B payload** (two TeeSets, gender-split ratings, extended fields):
        ```json
        {
          "name": "Hamburger Golf-Club Falkenstein",
          "clubName": "Hamburger Golf-Club e.V.",
          "country": "DE",
          "teeSets": [
            {
              "name": "Gelb",
              "sortOrder": 0,
              "ratings": [
                {
                  "ratingCategory": "MEN",
                  "routingType": "FULL_18",
                  "courseRating": 71.4,
                  "slopeRating": 135,
                  "par": 72,
                  "bogeyRating": 94.5,
                  "totalDistance": 6540,
                  "totalDistanceUnit": "YARDS",
                  "ratingAuthority": "DGV",
                  "ratedOn": "2023-05-15",
                  "frontCourseRating": 35.8,
                  "frontSlopeRating": 134,
                  "backCourseRating": 35.6,
                  "backSlopeRating": 136
                }
              ]
            },
            {
              "name": "Rot",
              "sortOrder": 1,
              "ratings": [
                {
                  "ratingCategory": "WOMEN",
                  "routingType": "FULL_18",
                  "courseRating": 73.2,
                  "slopeRating": 133,
                  "par": 73
                }
              ]
            }
          ]
        }
        ```
      properties:
        name:
          type: string
          example: "Hamburger Golf-Club Falkenstein"
          description: Full course name as printed on the scorecard.
        clubName:
          type: string
          nullable: true
          example: "Hamburger Golf-Club e.V."
          description: Organizing club name. Optional.
        country:
          type: string
          example: "DE"
          default: "DE"
          description: ISO 3166-1 alpha-2. Defaults to "DE".
        city:
          type: string
          nullable: true
          example: "Hamburg"
          description: City where the course is located. Optional.
        state:
          type: string
          nullable: true
          example: "Hamburg"
          description: Federal state or region. Optional.
        address:
          type: string
          nullable: true
          example: "In de Bargen 59"
          description: Street address. Optional.
        latitude:
          type: number
          format: float
          nullable: true
          example: 53.5753
          description: Geo latitude (WGS84). Optional.
        longitude:
          type: number
          format: float
          nullable: true
          example: 9.8606
          description: Geo longitude (WGS84). Optional.
        teeSets:
          type: array
          minItems: 1
          description: One or more TeeSets. Each TeeSet represents a physical tee marker (e.g. "Gelb").
          items:
            $ref: '#/components/schemas/CreateTeeSetRequest'

    CreateTeeSetRequest:
      type: object
      required: [name, ratings]
      description: One TeeSet (physical tee marker) with its WHS ratings.
      properties:
        name:
          type: string
          example: "Gelb"
          description: Physical tee marker name (e.g. "Gelb", "Weiß", "Rot", "Schwarz"). Defaults to "Standard" if empty.
        sortOrder:
          type: integer
          minimum: 0
          default: 0
          description: Display order within the course (0 = first). Defaults to insertion order if omitted.
        ratings:
          type: array
          minItems: 1
          description: |
            One or more WHS ratings for this TeeSet. Typically one per gender category.
            For a course with gender-neutral ratings, provide one rating with ratingCategory "UNSPECIFIED".
            For gender-differentiated ratings at the same tee color, provide two ratings (MEN + WOMEN).
          items:
            $ref: '#/components/schemas/CreateTeeRatingRequest'

    CreateTeeRatingRequest:
      type: object
      required: [ratingCategory, routingType, courseRating, slopeRating, par]
      description: |
        WHS rating data for one gender/routing combination at a TeeSet.

        **CR/SR/Par ranges by routing type:**

        | routingType | courseRating | par |
        |-------------|--------------|-----|
        | FULL_18     | 55.0 – 155.0 | 54 – 80 |
        | FRONT_NINE  | 25.0 – 77.5  | 27 – 40 |
        | BACK_NINE   | 25.0 – 77.5  | 27 – 40 |
        | COMPOSITE   | 25.0 – 77.5  | 27 – 40 |

        slopeRating is always 55 – 155 (WHS standard range).
      properties:
        ratingCategory:
          type: string
          enum: [MEN, WOMEN, UNSPECIFIED]
          example: "MEN"
          description: WHS rating applicability category. Use UNSPECIFIED for gender-neutral courses.
        routingType:
          type: string
          enum: [FULL_18, FRONT_NINE, BACK_NINE, COMPOSITE]
          default: FULL_18
          description: |
            Hole routing for this rating.
            - FULL_18: standard 18-hole course
            - FRONT_NINE: 9-hole route using holes 1–9
            - BACK_NINE: 9-hole route using holes 10–18
            - COMPOSITE: 18-hole route combining two 9-hole courses
        courseRating:
          type: number
          format: float
          example: 71.4
          description: WHS Course Rating — on the scorecard or club handicap sheet.
        slopeRating:
          type: integer
          minimum: 55
          maximum: 155
          example: 135
          description: WHS Slope Rating — on the scorecard or club handicap sheet.
        par:
          type: integer
          minimum: 27
          maximum: 80
          example: 72
          description: Course par for this routing type.
        bogeyRating:
          type: number
          format: float
          nullable: true
          example: 94.5
          description: WHS Bogey Rating. Optional.
        totalDistance:
          type: integer
          nullable: true
          example: 6540
          description: Total course length. Specify unit via `totalDistanceUnit`. Optional.
        totalDistanceUnit:
          type: string
          nullable: true
          enum: [YARDS, METERS]
          example: "YARDS"
          description: Unit for `totalDistance`. Required when `totalDistance` is provided. Optional.
        ratingAuthority:
          type: string
          nullable: true
          example: "DGV"
          description: Authority that issued this rating (e.g. DGV, USGA, R&A). Optional.
        ratedOn:
          type: string
          format: date
          nullable: true
          example: "2023-05-15"
          description: Date the official rating was issued (ISO date). Optional.
        frontCourseRating:
          type: number
          format: float
          nullable: true
          example: 35.8
          description: Course Rating for the front 9. For 18H courses with split ratings. Optional.
        frontSlopeRating:
          type: integer
          nullable: true
          example: 134
          description: Slope Rating for the front 9. Optional.
        frontBogeyRating:
          type: number
          format: float
          nullable: true
          example: null
          description: Bogey Rating for the front 9. Optional.
        backCourseRating:
          type: number
          format: float
          nullable: true
          example: 35.6
          description: Course Rating for the back 9. Optional.
        backSlopeRating:
          type: integer
          nullable: true
          example: 136
          description: Slope Rating for the back 9. Optional.
        backBogeyRating:
          type: number
          format: float
          nullable: true
          example: null
          description: Bogey Rating for the back 9. Optional.
        validFrom:
          type: string
          format: date
          default: "1970-01-01"
          example: "2023-05-15"
          description: Effective-from date for this rating. Defaults to 1970-01-01 (always active).
        holes:
          type: array
          description: Per-hole definitions. Optional at creation time; add later via PUT …/holes.
          default: []
          items:
            type: object
            required: [holeNumber, par]
            properties:
              holeNumber:
                type: integer
                minimum: 1
                maximum: 18
              par:
                type: integer
                enum: [3, 4, 5]
              strokeIndex:
                type: integer
                minimum: 1
                maximum: 18
                nullable: true
              yardage:
                type: integer
                nullable: true

    CourseListResponse:
      type: object
      required: [courses, total]
      properties:
        courses:
          type: array
          description: Paginated list of matching courses with full tee and rating data.
          items:
            $ref: '#/components/schemas/CourseOutput'
        total:
          type: integer
          description: Total matching courses (before pagination).
        query:
          type: string
          nullable: true
          description: The search query used, or null for list-all requests.


    RoundClassificationResponse:
      type: object
      description: Classification metadata returned with every round submission.
      properties:
        label:
          type: string
          enum: ["WHS Verified", "In-App Trend"]
          description: Human-readable classification label. "WHS Verified" for official rounds; "In-App Trend" for Quick Track.
          example: "WHS Verified"
        whsVerified:
          type: boolean
          description: True for official WHS rounds, false for Quick Track.
        badge:
          type: string
          enum: [gold, subtle]
          description: UI badge style hint. "gold" for WHS Verified, "subtle" for In-App Trend.
        source:
          type: string
          enum: [official, informal]
          description: Machine-readable classification source. "official" for WHS rounds; "informal" for Quick Track rounds that do not affect Handicap Index.

    DeleteRoundResponse:
      type: object
      required: [deleted, roundId, message]
      properties:
        deleted:
          type: boolean
          description: Confirmation that the round was successfully deleted.
          example: true
        roundId:
          type: string
          format: uuid
          description: ID of the deleted round.
        indexBefore:
          type: number
          nullable: true
          description: Handicap Index before deletion. Null if not calculable.
          example: 24.3
        indexAfter:
          type: number
          nullable: true
          description: Handicap Index after deletion. Null if insufficient rounds remain.
          example: 25.1
        delta:
          type: number
          nullable: true
          description: indexAfter minus indexBefore. Positive = worse, negative = better.
          example: 0.8
        message:
          type: string
          description: Human-readable impact summary.
          example: "Runde gelöscht. Dein Index steigt von 24.3 auf 25.1."

    RoundListResponse:
      type: object
      required: [rounds, total]
      properties:
        rounds:
          type: array
          description: Paginated list of round summaries for this player, sorted by playedAt descending.
          items:
            $ref: '#/components/schemas/RoundSummary'
        total:
          type: integer
          description: Total rounds for this player (before pagination).
        effectiveIndex:
          type: number
          nullable: true
          description: Current effective index used for relativeToIndex calculation.
        limit:
          type: integer
          description: Maximum number of rounds returned in this response (mirrors request parameter).
        offset:
          type: integer
          description: Pagination offset applied (mirrors request parameter).
        courseFilter:
          type: string
          nullable: true
          description: Course name filter applied to this result set. Null when no filter was used.

    RoundSummary:
      type: object
      required: [roundId, courseDisplay, playedAt, roundStatus, roundType, usedInCalculation]
      properties:
        roundId:
          type: string
          format: uuid
          description: Unique round identifier. Use this to fetch details or delete a round.
        courseDisplay:
          type: string
          example: "Strandgrün Nordplatz (Gelb tees)"
        playedAt:
          type: string
          format: date
          example: "2026-05-17"
        roundStatus:
          type: string
          enum: [official, pending_course_data, promotion_failed]
          description: |
            `official` — round is in the WHS pool with a calculated differential.
            `pending_course_data` — HoleByHole round saved but SI data was missing; will auto-promote when hole data is contributed.
            `promotion_failed` — SI data was contributed but AGS calculation failed (e.g. scores out of WHS bounds).
          example: official
        roundType:
          type: string
          enum: [18hole, 9front, 9back]
          description: Round format that determines WHS differential calculation path.
          example: "18hole"
        adjustedGrossScore:
          type: integer
          nullable: true
          description: Null for pending/failed rounds.
          example: 92
        differential:
          type: number
          nullable: true
          description: Null for pending/failed rounds.
          example: 18.3
        toPar:
          type: string
          nullable: true
          description: Null for pending/failed rounds.
          example: "+20"
        usedInCalculation:
          type: boolean
          description: Whether this round counts toward the current Handicap Index.
        relativeToIndex:
          type: number
          nullable: true
          description: Differential minus effective index. Negative = better than index.
          example: -1.4
        relativeLabel:
          type: string
          nullable: true
          example: "Besser als Index"
        grossTotal:
          type: integer
          nullable: true
          description: |
            Sum of all hole scores as entered by the player (Bruttoschläge), before NDB capping.
            Only available for `hole_by_hole` input mode. `null` for `stableford`, `direct_ags`,
            and legacy rounds without raw input.
          example: 95
        expectedScoreUsed:
          type: number
          format: float
          nullable: true
          description: |
            Expected Score (WHS §6.1) applied when combining two 9-hole rounds into an 18-hole composite differential.
            Non-null only for `9front`/`9back` rounds that were completed via the Expected-Score path.
          example: 38.2
        rawInput:
          type: object
          nullable: true
          description: |
            Original score-input payload as stored at submission time (ADR-024).
            `null` for legacy rounds created before raw-input persistence (pre-ADR-022).
            Shape varies by `inputMode`:
            - `hole_by_hole` — includes `holeScores` array and `teeRatingId`
            - `stableford`   — includes `stablefordPoints` and `courseHandicap`
            - `direct_ags`   — includes `adjustedGrossScore`
          properties:
            inputMode:
              type: string
              enum: [hole_by_hole, stableford, direct_ags]
              example: hole_by_hole
          additionalProperties: true

    HealthResult:
      type: object
      required: [status, version, checks, timestamp]
      properties:
        status:
          type: string
          enum: [healthy, unhealthy]
          description: Overall system status. "healthy" when all checks pass; "unhealthy" when at least one check fails.
          example: healthy
        version:
          type: string
          description: API version string.
          example: "2.2.0"
        checks:
          type: object
          description: Map of component name to check result. All components must return "ok" for overall status to be "healthy".
          additionalProperties:
            type: string
            enum: [ok, error]
          example:
            database: ok
        timestamp:
          type: string
          format: date-time
          description: ISO 8601 timestamp of the health check.
          example: "2026-05-25T10:00:00+02:00"

    ApiError:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message]
          properties:
            code:
              type: string
              enum: [player_not_found, insufficient_score_history, invalid_input,
                     stroke_index_unavailable, unauthorized, conflict, internal_error]
              description: Machine-readable error code. Use this for programmatic error handling rather than parsing `message`.
              example: invalid_input
            message:
              type: string
              description: Human-readable error description. Not stable across versions — use `code` for error handling.
              example: "Score (300) must be between 18 and 270."

    RegisterResponse:
      type: object
      required: [message]
      properties:
        message:
          type: string
          description: User-facing confirmation message. The response is intentionally ambiguous to prevent email enumeration.
          example: "If this email address is not yet registered, your account has been created. You can now log in."

    AuthResponse:
      type: object
      required: [token, expiresAt, playerId, playerName, email, refreshToken, refreshTokenExpiresIn]
      properties:
        token:
          type: string
          description: "Short-lived access JWT (15 min). Use refreshToken to obtain a new one."
          example: "eyJhbGciOiJIUzI1NiJ9..."
        expiresAt:
          type: string
          format: date-time
          description: ISO 8601 expiry timestamp for the access token.
        playerId:
          type: string
          format: uuid
          description: Unique player identifier for the authenticated user.
        playerName:
          type: string
          example: "Alice"
          description: Display name of the authenticated player.
        email:
          type: string
          format: email
          description: Email address of the authenticated player.
        refreshToken:
          type: string
          description: "Opaque 30-day refresh token. Exchange via POST /auth/token/refresh."
          example: "a3f8c1d2e4b5..."
        refreshTokenExpiresIn:
          type: integer
          description: "Refresh token lifetime in seconds (default: 2592000 = 30 days)."
          example: 2592000

    MfaRequiredResponse:
      type: object
      required: [mfa_required, mfa_methods, partial_token]
      properties:
        mfa_required:
          type: boolean
          const: true
          description: Always true — signals that a second factor is required to complete login.
        mfa_methods:
          type: array
          description: Available second-factor methods for this account.
          items:
            type: string
            enum: [totp, backup_code]
          example: [totp]
        partial_token:
          type: string
          description: "Short-lived JWT (TTL: 5 min) for /auth/mfa/verify step 2."

    MfaSetupRequiredResponse:
      type: object
      required: [status, setup_token]
      description: >
        Returned when an admin logs in but has no active TOTP credential.
        Use setup_token (TTL: 10 min) as Bearer token for GET /me/mfa/totp/setup
        and POST /me/mfa/totp/verify. After successful enrollment those endpoints
        return a partial_token for immediate use with POST /auth/mfa/verify.
      properties:
        status:
          type: string
          const: mfa_setup_required
        setup_token:
          type: string
          description: "Short-lived JWT (TTL: 10 min) granting access only to TOTP enrollment endpoints."

    TokenRefreshResponse:
      type: object
      required: [access_token, refresh_token, expires_in, token_type]
      properties:
        access_token:
          type: string
          description: "New short-lived access JWT."
          example: "eyJhbGciOiJIUzI1NiJ9..."
        refresh_token:
          type: string
          description: "New opaque refresh token (old token is invalidated)."
          example: "b4e9d2f1a5c6..."
        expires_in:
          type: integer
          description: "Access token lifetime in seconds (900 = 15 min)."
          example: 900
        token_type:
          type: string
          example: "Bearer"

    MeResponse:
      type: object
      required: [playerId, playerName, handicapIndex, roundsPosted, readyForInsights, hasHandicapSeed, onboarding]
      properties:
        playerId:
          type: string
          format: uuid
          description: Unique player identifier for the authenticated user.
        playerName:
          type: string
          description: Display name of the authenticated player.
        handicapIndex:
          type: number
          format: float
          nullable: true
          description: Current Handicap Index, or null when no rounds and no seed are available.
        handicapSource:
          type: string
          nullable: true
          enum: [calculated, bootstrapped]
          description: How the current index was derived. Null when no index is set.
        handicapSourceLabel:
          type: string
          nullable: true
          description: German-language label for the source (e.g. "Berechnet nach WHS"). Null when no index is set.
        roundsPosted:
          type: integer
          description: Total number of WHS rounds posted by this player.
        readyForInsights:
          type: boolean
          description: True when the player has enough rounds (≥ 3) for the Insights and Recommendations engines.
        hasHandicapSeed:
          type: boolean
          description: Whether a bootstrap seed index has been set for this player.
        seed:
          nullable: true
          type: object
          description: The current bootstrap seed, if any. Null when no seed is set or source is calculated.
          properties:
            value:
              type: number
              format: float
              example: 14.2
            source:
              type: string
              enum: [dgv, official, manual, estimated]
            label:
              type: string
              description: Human-readable source label.
              example: "DGV Ausweis"
            note:
              type: string
              nullable: true
            seededAt:
              type: string
              format: date
        relativePerformance:
          nullable: true
          type: object
          description: Recent form analysis relative to current effective index. Null during onboarding.
          properties:
            delta:
              type: number
              format: float
              description: recentAvgDifferential − referenceIndex. Negative = playing better.
            signal:
              type: string
              enum: [above_index, below_index, on_track, no_data]
            trajectory:
              type: string
              enum: [improving, declining, stable]
              description: |
                Longitudinal trend computed via least-squares slope over recent rounds.
                "improving" = differentials decreasing over time (player getting better).
            narrative:
              type: string
              description: Trajectory-aware human-readable summary.
            impliedIndex:
              type: number
              format: float
              description: Handicap Index implied by recent form (short-window average × 0.96).
            roundsAnalyzed:
              type: integer
              description: Number of recent rounds used for the relative performance calculation.
        onboarding:
          nullable: true
          type: object
          description: Onboarding guidance for new players. Null once the player is fully established.
          properties:
            message:
              type: string
              description: German-language onboarding hint for the current state.
            roundsNeeded:
              type: integer
              description: Number of additional rounds needed to reach the next fidelity level.
            nextStep:
              type: string
              description: Machine-readable code for the next recommended action (e.g. "post_round").
        isAdmin:
          type: boolean
          description: Whether the authenticated user has admin privileges. Use this to conditionally show admin navigation.
          example: false

    PostRoundRequest:
      type: object
      required: [playedAt]
      description: |
        **Progressive Disclosure — two submission modes:**

        **Mode WHS (Official):** Provide score data + course data → stored in WHS engine.
        Response includes `"classification": { "label": "WHS Verified", "whsVerified": true }`.

        **Mode Quick Track (Informal):** Provide only `grossScore` → stored in analytics only.
        Response includes `"classification": { "label": "In-App Trend", "whsVerified": false }`.

        For 9-hole rounds: set `roundType` to `9front` or `9back`.

        **AGS resolution — three input paths (exactly one required for WHS):**

        **Path 1 — Hole-by-hole (recommended, WHS-exact):** Provide `holeScores` array + `courseId` + `teeRatingId`.
        The engine applies NDB cap per hole and derives the WHS-correct AGS automatically.
        Requires stroke index data to be present in the course catalogue.

        **Path 2 — Stableford back-calculation:** Provide `stablefordPoints` + `courseHandicap` + `courseId`.
        AGS is reconstructed from the Stableford total.

        **Path 3 — Direct AGS (legacy):** Provide `adjustedGrossScore` directly.
        The caller is responsible for correct NDB capping.

        **Course data — two posting modes:**

        **Mode A — Course catalogue (recommended):** Provide `courseId` and optionally `teeRatingId`.
        CR/SR/Par are loaded from the catalogue automatically.
        Use `GET /v1/courses?q=...` to find a course and its tees first.

        **Mode B — Manual entry (backward-compatible):** Provide `courseRating`, `slopeRating`, `par` directly.
        Used when a course is not in the catalogue (Path 3 only).
      properties:
        courseId:
          type: string
          format: uuid
          description: "Mode A: course from the catalogue. Omit for manual entry (Mode B)."
          example: "11111111-0001-0000-0000-000000000001"
        teeRatingId:
          type: string
          format: uuid
          description: "Mode A (preferred): UUID of the TeeRating from GET /courses/{id}. Uniquely identifies tee + gender + routing."
          example: "b2c3d4e5-0001-0000-0000-000000000001"
        teeName:
          type: string
          description: "Mode A (legacy fallback): compound tee name e.g. \"Gelb (Herren)\". Use teeRatingId instead."
          example: "Gelb (Herren)"
        roundType:
          type: string
          enum: [18hole, 9front, 9back]
          default: 18hole
          description: |
            Whether this is a full 18-hole or a 9-hole round.
            For 9-hole rounds the system applies the WHS 2024 Expected Score mechanism.

            **9-hole prerequisite:** A Handicap Index must be established before a 9-hole
            round can be submitted. The index is resolved from the player's handicap history
            or their profile seed. If neither is available, a `422` error with code
            `invalid_input` is returned. Resolve by either submitting an 18-hole round first
            or setting a handicap seed via `PATCH /v1/players/{playerId}/seed`.
        grossScore:
          type: integer
          description: |
            **Quick Track only.** Gross score without official course data.
            Triggers the In-App Trend path — does NOT affect Handicap Index.
          example: 45
        adjustedGrossScore:
          type: integer
          minimum: 18
          maximum: 270
          description: "Path 3 — Direct AGS. Caller must apply WHS NDB cap before submitting."
          example: 82
        holeScores:
          type: array
          description: |
            **Path 1 — Hole-by-hole.** Array of 9 or 18 gross scores (one per hole, in hole order).
            Requires `courseId` + `teeRatingId`. Engine applies NDB cap automatically.
          items:
            type: integer
            minimum: 1
            maximum: 15
          minItems: 9
          maxItems: 18
          example: [4, 5, 3, 6, 4, 4, 5, 3, 4, 5, 4, 3, 6, 4, 4, 5, 3, 4]
        stablefordPoints:
          type: integer
          description: |
            **Path 2 — Stableford back-calculation.** Total Stableford points scored.
            Requires `courseHandicap` and `courseId`.
          minimum: 0
          maximum: 72
          example: 34
        courseHandicap:
          type: integer
          description: "Required with `stablefordPoints`. Course handicap used during the round."
          minimum: -10
          maximum: 99
          example: 18
        courseRating:
          type: number
          format: float
          minimum: 55.0
          maximum: 155.0
          example: 72.0
          description: "Mode B only: Course Rating for manual entry. Omit when using courseId (Mode A)."
        slopeRating:
          type: integer
          minimum: 55
          maximum: 155
          example: 128
          description: "Mode B only: Slope Rating for manual entry. Omit when using courseId (Mode A)."
        par:
          type: integer
          minimum: 54
          maximum: 80
          example: 72
          description: "Mode B only: Course par for manual entry. Omit when using courseId (Mode A)."
        playedAt:
          type: string
          format: date
          example: "2025-06-01"
          description: Date the round was played (ISO 8601). Must not be in the future.
        courseName:
          type: string
          example: "Royal Troon"
          description: "Mode B only: free-text course name for manual entry. Stored as display label when no courseId is provided."

    RoundFeedback:
      type: object
      required: [differentialLabel, toPar, message]
      properties:
        differentialLabel:
          type: string
          description: "Formatted score differential label for immediate display (e.g. \"Your differential: +10.0\")."
          example: "Your differential: +10.0"
        toPar:
          type: string
          description: Score relative to course par as a signed string (e.g. "+10", "-2", "E").
          example: "+10"
        message:
          type: string
          description: Short human-readable performance verdict based on the differential value.
          example: "Average round."

    RoundResponse:
      type: object
      required: [roundId, playerId, adjustedGrossScore, scoreDifferential,
                 courseRating, slopeRating, par, playedAt, feedback]
      properties:
        roundId:
          type: string
          format: uuid
          description: Unique identifier of the newly created round.
        playerId:
          type: string
          format: uuid
          description: Player who posted the round.
        adjustedGrossScore:
          type: integer
          description: AGS after NDB capping, as stored in the WHS calculation pool.
        scoreDifferential:
          type: number
          format: float
          description: Calculated WHS Score Differential — (AGS − CR) × 113 / SR.
        courseRating:
          type: number
          format: float
          description: Course Rating used for this round's differential calculation.
        slopeRating:
          type: integer
          description: Slope Rating used for this round's differential calculation.
        par:
          type: integer
          description: Course par used for this round.
        playedAt:
          type: string
          format: date
          description: Date the round was played (ISO 8601).
        courseName:
          type: string
          nullable: true
          description: Display name of the course. Null for Mode B rounds without a courseName.
        feedback:
          $ref: '#/components/schemas/RoundFeedback'

    PendingRoundAccepted:
      type: object
      required: [roundId, playerId, playedAt, roundType, status, feedback]
      description: |
        Returned by `POST /v1/rounds` when a HoleByHole round is accepted but cannot be
        calculated immediately (stroke index data missing). The round will be promoted
        to an official round automatically once hole data is contributed.
      properties:
        roundId:
          type: string
          format: uuid
          description: ID of the pending round.
        playerId:
          type: string
          format: uuid
        playedAt:
          type: string
          format: date
        roundType:
          type: string
          enum: [18hole, 9front, 9back]
        status:
          type: string
          enum: [pending_course_data]
        feedback:
          type: object
          required: [message]
          properties:
            message:
              type: string
              example: "Runde gespeichert. Wird automatisch berechnet, sobald Lochdaten für diesen Abschlag hinterlegt sind."

    DifferentialDetail:
      type: object
      required: [roundId, value, playedAt, usedInCalculation, isAnchor]
      properties:
        roundId:
          type: string
          format: uuid
          description: Round identifier. For virtual anchor entries derived from HandicapSeed this is a synthetic UUID (not a real round ID).
        value:
          type: number
          format: float
          example: 8.4
          description: Score Differential value for this entry.
        playedAt:
          type: string
          format: date
          description: Date of the round (ISO 8601). For anchor entries this reflects the seed date.
        courseRating:
          type: number
          format: float
          nullable: true
          description: Null for anchor differentials derived from HandicapSeed.
        slopeRating:
          type: integer
          nullable: true
          description: Null for anchor differentials derived from HandicapSeed.
        usedInCalculation:
          type: boolean
          description: Whether this differential is among the best-N selected for the current Handicap Index.
        isAnchor:
          type: boolean
          description: True when this entry is a virtual anchor derived from HandicapSeed (ADR-027), not a real played round.

    HandicapResponse:
      type: object
      required: [playerId, handicapIndex, isCapped, totalRoundsConsidered,
                 differentialsUsedCount, averageBeforeAdjustment, differentials,
                 explanation, calculatedAt, source, sourceLabel,
                 dataFidelity, realRoundsInWindow, anchorSlotsActive]
      properties:
        playerId:
          type: string
          format: uuid
          description: Player whose Handicap Index this response describes.
        handicapIndex:
          type: number
          format: float
          minimum: -10.0
          maximum: 54.0
          example: 14.2
          description: Current WHS Handicap Index, clamped to [−10.0, 54.0].
        isCapped:
          type: boolean
          description: True when the Soft Cap or Hard Cap was applied during calculation.
        source:
          type: string
          enum: [calculated, bootstrapped, anchored]
          description: >
            How the Handicap Index was derived.
            `calculated` = WHS engine, real rounds only.
            `bootstrapped` = manually seeded value; no own rounds posted.
            `anchored` = hybrid pool (real rounds + AnchorDifferentials from HandicapSeed).
        sourceLabel:
          type: string
          description: Human-readable German label for the source.
          example: "Berechnet nach WHS"
        dataFidelity:
          type: string
          enum: [no_scores, seeded, initial, anchored, established]
          description: >
            WHS data-fidelity state (ADR-027).
            `established` = 8+ real rounds, full WHS calculation.
            `initial` = 1–7 real rounds, WHS Table 5.2a with adjustments.
            `anchored` = real rounds + virtual anchors from HandicapSeed.
            `seeded` = no own rounds, index from HandicapSeed only.
            `no_scores` = no rounds, no seed.
        realRoundsInWindow:
          type: integer
          description: Number of real played rounds contributing to this index.
        anchorSlotsActive:
          type: integer
          description: Number of virtual AnchorDifferential slots active in the calculation pool (ADR-027).
        totalRoundsConsidered:
          type: integer
          description: Total number of entries in the 20-round window (real rounds + anchor slots).
        differentialsUsedCount:
          type: integer
          description: Number of differentials selected as the best-N from the window.
        averageBeforeAdjustment:
          type: number
          format: float
          description: Arithmetic mean of the selected differentials before the 0.96 WHS adjustment factor.
        differentials:
          type: array
          description: All entries in the 20-round calculation window, sorted by playedAt descending.
          items:
            $ref: '#/components/schemas/DifferentialDetail'
        explanation:
          type: string
          description: German-language WHS calculation narrative.
          example: "Aus 8 Runden werden die 5 besten Ergebnisse verwendet. Durchschnitt der 5 besten Ergebnisse: 12.4. Nach WHS-Korrekturfaktor (×0.96): 11.9."
        explanationDetail:
          type: object
          description: Machine-readable breakdown of each calculation step.
          properties:
            roundsInWindow:
              type: integer
              example: 8
            differentialsSelected:
              type: integer
              example: 5
            selectionRule:
              type: string
              example: "Aus 8 Runden werden die 5 besten Ergebnisse verwendet."
            averageOfSelected:
              type: number
              format: float
              example: 12.4
            adjustmentFactor:
              type: number
              format: float
              example: 0.96
            adjustedValue:
              type: number
              format: float
              example: 11.9
            wasCapped:
              type: boolean
              example: false
        calculatedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp when this Handicap Index was last calculated.
        esrApplied:
          type: boolean
          description: WHS §5.9 Exceptional Score Reduction was applied (only present when true).
        esrReduction:
          type: number
          format: float
          description: The reduction amount applied (always 1.0 when esrApplied is true).

    SimulationResponse:
      type: object
      required: [playerId, currentHandicapIndex, simulatedHandicapIndex, delta, isImprovement, isSimulation]
      properties:
        playerId:
          type: string
          format: uuid
          description: Player for whom the simulation was run.
        currentHandicapIndex:
          type: number
          format: float
          description: Actual current Handicap Index before the simulated round.
        simulatedHandicapIndex:
          type: number
          format: float
          description: Projected Handicap Index if the simulated score were added to the calculation pool.
        delta:
          type: number
          format: float
          description: simulatedHandicapIndex minus currentHandicapIndex. Negative = index would improve.
        isImprovement:
          type: boolean
          description: True when the simulated round would lower (improve) the Handicap Index.
        isSimulation:
          type: boolean
          description: Always true — indicates this is a projected value, not a stored round.

    TargetSimulationRequest:
      type: object
      required: [targetHandicapIndex, courseRating, slopeRating, par]
      properties:
        targetHandicapIndex:
          type: number
          format: float
          example: 10.0
        courseRating:
          type: number
          format: float
          example: 72.0
        slopeRating:
          type: integer
          example: 128
        par:
          type: integer
          example: 72

    PlayerStrategyGuide:
      type: object
      description: Player-friendly translation of the target scenario into actionable round guidance. Hides WHS jargon — values are pre-calculated for the golfer.
      required: [courseHandicap, targetAgs, strokesOverPar, neededNetPoints, allowedBogeys, allowedDoubles, allowedPars, isAchievable]
      properties:
        courseHandicap:
          type: integer
          description: "WHS Course Handicap: round(hcpi × (SR/113) + (CR−Par))"
        targetAgs:
          type: integer
          description: Required adjusted gross score to reach the target index
        strokesOverPar:
          type: integer
          description: targetAgs minus course par
        neededNetPoints:
          type: integer
          description: "Approximate net Stableford points needed: 36 + (courseHandicap − strokesOverPar)"
        allowedBogeys:
          type: integer
          description: Maximum number of bogeys (1-over par) the player can make and still reach the target.
        allowedDoubles:
          type: integer
          description: Maximum number of double bogeys the player can make and still reach the target.
        allowedPars:
          type: integer
          description: Minimum number of pars needed to reach the target score.
        isAchievable:
          type: boolean
          description: True when the required AGS is within realistic WHS scoring bounds for this course.

    TargetSimulationResponse:
      type: object
      required: [playerId, currentHandicapIndex, scenario, playerStrategy, isSimulation]
      properties:
        playerId:
          type: string
          format: uuid
        currentHandicapIndex:
          type: number
          format: float
        scenario:
          type: object
          properties:
            targetHandicapIndex:
              type: number
              format: float
              description: The target Handicap Index as requested.
            requiredDifferential:
              type: number
              format: float
              description: Score Differential needed in the simulated round to reach targetHandicapIndex.
            requiredAdjustedGrossScore:
              type: integer
              description: AGS that produces the requiredDifferential on the specified course.
            courseRating:
              type: number
              format: float
              description: Course Rating used for the reverse calculation.
            slopeRating:
              type: integer
              description: Slope Rating used for the reverse calculation.
            par:
              type: integer
              description: Course par used for the reverse calculation.
            isAchievable:
              type: boolean
              description: True when the required AGS is within realistic WHS scoring bounds for this course.
            narrativeSummary:
              type: string
              description: German-language plain-text summary of the scenario (e.g. "Du musst 84 oder besser spielen").
        playerStrategy:
          $ref: '#/components/schemas/PlayerStrategyGuide'
        isSimulation:
          type: boolean

    ImpactNarrative:
      type: object
      required: [reasonCode, explanation]
      properties:
        reasonCode:
          type: string
          enum: [low_differential_anchor, high_differential_drag, high_impact_expiry,
                 selected_best_differential, excluded_not_best]
          description: Machine-readable reason code for why this round has the described impact.
        explanation:
          type: string
          description: German-language human-readable explanation of this round's impact on the Handicap Index.
        indexDeltaIfRemoved:
          type: number
          format: float
          nullable: true
          description: How much the Handicap Index would change if this round were deleted. Positive = index would worsen. Null if not calculable.
        replacesRoundId:
          type: string
          nullable: true
          description: Round ID that would be promoted into the best-N selection if this round expired. Null if no replacement is identified.

    ImpactRound:
      type: object
      required: [roundId, differential, playedAt, course, selectedForCalculation,
                 impactScore, impactStrength, roundsUntilExpiry, isHighImpactExpiry,
                 isReplacementCandidate, narrative]
      properties:
        roundId:
          type: string
          format: uuid
          description: Unique round identifier.
        differential:
          type: number
          format: float
          description: Score Differential for this round.
        playedAt:
          type: string
          format: date
          description: Date the round was played (ISO 8601).
        course:
          type: string
          description: Display name of the course for this round.
        selectedForCalculation:
          type: boolean
          description: True when this round is in the best-N selection contributing to the current Handicap Index.
        impactScore:
          type: number
          format: float
          minimum: -1.0
          maximum: 1.0
          description: Normalized impact score in [−1, 1]. Negative values mean this round pulls the index down (improves it); positive values raise it.
        impactStrength:
          type: string
          enum: [strong_positive, moderate_positive, neutral, moderate_negative, strong_negative]
          description: Categorical impact label derived from impactScore.
        roundsUntilExpiry:
          type: integer
          description: Number of future rounds until this round drops out of the 20-round window.
        isHighImpactExpiry:
          type: boolean
          description: True when this round will have a significant index effect when it expires.
        isReplacementCandidate:
          type: boolean
          description: True when this round could replace another expiring round in the best-N selection.
        narrative:
          $ref: '#/components/schemas/ImpactNarrative'

    ImpactAnalysisResponse:
      type: object
      properties:
        playerId:
          type: string
          format: uuid
          description: Player whose round impact was analysed.
        totalRoundsAnalyzed:
          type: integer
          description: Total rounds analysed (up to 20-round window).
        roundsSelectedForCalculation:
          type: integer
          description: Number of rounds in the best-N selection for the current index.
        highestImpactRound:
          nullable: true
          description: The single round with the greatest absolute impact on the current index. Null when insufficient data.
          allOf:
            - $ref: '#/components/schemas/ImpactRound'
        expiryWarnings:
          type: array
          description: Rounds expiring soon that will materially affect the index when they fall out of the window.
          items:
            type: object
            properties:
              roundId:
                type: string
                description: Round identifier.
              differential:
                type: number
                description: Score Differential of the expiring round.
              roundsUntilExpiry:
                type: integer
                description: Rounds remaining before this entry exits the 20-round window.
              message:
                type: string
                description: German-language expiry warning message.
        rounds:
          type: array
          description: All rounds in the 20-round window with full impact analysis, sorted by impactScore descending.
          items:
            $ref: '#/components/schemas/ImpactRound'

    TrendAnalysisResponse:
      type: object
      properties:
        playerId:
          type: string
          format: uuid
          description: Player whose trends were analysed.
        currentHandicapIndex:
          type: number
          format: float
          description: Current WHS Handicap Index (full 20-round window).
        shortWindowIndex:
          type: number
          format: float
          description: Index computed from recent rounds only (short window, typically last 5).
        shortWindowDelta:
          type: number
          format: float
          description: shortWindowIndex minus currentHandicapIndex. Negative = recent form is better than overall index.
        overallDirection:
          type: string
          enum: [improving, worsening, stable]
          description: Long-term trend direction computed over the full 20-round window.
        recentDirection:
          type: string
          enum: [improving, worsening, stable]
          description: Short-term trend direction computed over recent rounds only.
        isHot:
          type: boolean
          description: True when recent rounds are consistently better than the current index (on a hot streak).
        isCold:
          type: boolean
          description: True when recent rounds are consistently worse than the current index (on a cold streak).
        volatility:
          type: object
          description: Statistical consistency measures computed over the analysis window.
          properties:
            spread:
              type: number
              description: Range between the best and worst differential in the window (max − min).
            standardDeviation:
              type: number
              description: Standard deviation of differentials in the window.
            consistencyScore:
              type: number
              minimum: 0.0
              maximum: 1.0
              description: Normalized consistency score in [0, 1]. 1.0 = perfectly consistent; 0.0 = highly volatile.
            consistencyLabel:
              type: string
              description: German-language label for the consistency score (e.g. "sehr konsistent", "volatil").
            sampleSize:
              type: integer
              description: Number of rounds used for volatility analysis.
        volatilityNarrative:
          type: string
          description: German-language plain-text summary of consistency and volatility findings.

    ExplainabilityReference:
      type: object
      required: [source, detail]
      properties:
        source:
          type: string
          enum: [impact_score, expiry_risk, trend_direction, volatility,
                 replacement_candidate, calculation_window, target_scenario]
          description: Which analysis module produced this reference.
        detail:
          type: string
          description: German-language explanation of the referenced data point.
        roundId:
          type: string
          nullable: true
          description: Round ID relevant to this reference. Null when not round-specific.
        metric:
          type: string
          nullable: true
          description: "Named metric value that supports this reference (e.g. \"impactScore: -0.42\"). Null if not applicable."

    Insight:
      type: object
      required: [id, category, categoryLabel, severity, title, narrative,
                 supportingFactors, references, generatedAt]
      properties:
        id:
          type: string
          description: Unique insight identifier (deterministic, derived from category + contributing data).
          example: "expiry_imminent_a1b2c3d4"
        category:
          type: string
          enum: [trend, stability, risk, improvement, replacement, expiry, consistency]
          description: Insight category. Drives filtering and grouping in client UIs.
        categoryLabel:
          type: string
          description: Human-readable German label for the category (e.g. "Ablauf-Risiko").
        severity:
          type: string
          enum: [info, notice, important, critical]
          description: Severity level. Use to prioritize rendering order and highlight critical insights.
        title:
          type: string
          description: Short German-language insight headline.
        narrative:
          type: string
          description: German-language explanation of the insight — what it means and why it matters to the player.
        supportingFactors:
          type: array
          description: List of German-language bullet-point facts that support this insight.
          items:
            type: string
        references:
          type: array
          description: Machine-readable references linking this insight to analysis data points.
          items:
            $ref: '#/components/schemas/ExplainabilityReference'
        generatedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp when this insight was generated.

    InsightsResponse:
      type: object
      properties:
        playerId:
          type: string
          format: uuid
          description: Player for whom insights were generated.
        currentHandicapIndex:
          type: number
          format: float
          description: Current Handicap Index at the time of insight generation.
        summary:
          type: object
          description: Aggregate counts for client-side badge rendering.
          properties:
            totalInsights:
              type: integer
              description: Total number of insights generated for this player.
            criticalCount:
              type: integer
              description: Number of insights with severity "critical".
            importantCount:
              type: integer
              description: Number of insights with severity "important".
        insights:
          type: array
          description: All insights sorted by severity (critical first), then generatedAt descending.
          items:
            $ref: '#/components/schemas/Insight'

    RecommendationWindow:
      type: object
      properties:
        expiryRounds:
          type: integer
          nullable: true
          description: Rounds remaining before a relevant round expires. Null for non-expiry recommendations.
        description:
          type: string
          description: German-language description of the action window (e.g. "Innerhalb der nächsten 3 Runden").

    RecommendationReference:
      type: object
      required: [source, reasoning]
      properties:
        source:
          type: string
          enum: [insight_expiry_risk, insight_trend_hot, insight_replacement,
                 insight_volatility, calculation_window, impact_score]
          description: Analysis signal that drove this recommendation.
        reasoning:
          type: string
          description: German-language explanation of why this signal led to the recommendation.
        roundId:
          type: string
          nullable: true
          description: Specific round relevant to this reference. Null if not round-specific.
        metric:
          type: string
          nullable: true
          description: "Named metric value (e.g. \"consistencyScore: 0.34\"). Null if not applicable."
        insightId:
          type: string
          nullable: true
          description: Insight ID that contributed to this recommendation. Null if not insight-derived.

    Recommendation:
      type: object
      required: [id, category, categoryLabel, priority, confidence, confidenceLabel,
                 actionability, isActionable, window, action, rationale,
                 supportingFactors, references, generatedAt]
      properties:
        id:
          type: string
          description: Unique recommendation identifier (deterministic, derived from category + signals).
        category:
          type: string
          enum: [scoring, window, consistency, course, timing, risk]
          description: Recommendation category — what aspect of the player's game it addresses.
        categoryLabel:
          type: string
          description: Human-readable German label for the category.
        priority:
          type: string
          enum: [low, medium, high, critical]
          description: Priority level. Use to sort and highlight the most actionable recommendation.
        confidence:
          type: string
          enum: [high, medium, low, speculative]
          description: How confident the engine is in this recommendation based on available data.
        confidenceLabel:
          type: string
          description: Human-readable German label for the confidence level.
        actionability:
          type: string
          enum: [immediate, near_term, strategic, monitoring]
          description: When the player should act on this recommendation.
        isActionable:
          type: boolean
          description: True when there is a concrete action the player can take right now.
        window:
          $ref: '#/components/schemas/RecommendationWindow'
        action:
          type: string
          description: German-language imperative action description (e.g. "Spiele innerhalb der nächsten 2 Runden auf einem anspruchsvollen Platz").
        rationale:
          type: string
          description: German-language explanation of why this action is recommended.
        supportingFactors:
          type: array
          description: German-language bullet-point facts that support this recommendation.
          items:
            type: string
        references:
          type: array
          description: Machine-readable references linking this recommendation to analysis signals.
          items:
            $ref: '#/components/schemas/RecommendationReference'
        conflictGroup:
          type: string
          nullable: true
          description: Conflict group identifier. Only one recommendation per group is shown at a time (deduplication). Null for non-conflicting recommendations.
        generatedAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp when this recommendation was generated.

    RecommendationsResponse:
      type: object
      properties:
        playerId:
          type: string
          format: uuid
          description: Player for whom recommendations were generated.
        currentHandicapIndex:
          type: number
          format: float
          description: Current Handicap Index at the time of recommendation generation.
        summary:
          type: object
          description: Aggregate counts for client-side badge rendering.
          properties:
            totalRecommendations:
              type: integer
              description: Total number of recommendations generated.
            criticalCount:
              type: integer
              description: Number of recommendations with priority "critical".
            highCount:
              type: integer
              description: Number of recommendations with priority "high".
        recommendations:
          type: array
          description: All recommendations sorted by priority (critical first), then confidence descending.
          items:
            $ref: '#/components/schemas/Recommendation'

    PlayerContextResponse:
      type: object
      properties:
        playerId:
          type: string
          format: uuid
          description: Unique player identifier.
        playerName:
          type: string
          description: Player's display name.
        currentHandicapIndex:
          type: number
          format: float
          description: Current WHS Handicap Index.
        isCapped:
          type: boolean
          description: True when the Soft Cap or Hard Cap was applied.
        roundsInWindow:
          type: integer
          description: Number of rounds in the active 20-round calculation window.
        trend:
          type: object
          description: Compact trend summary (subset of TrendAnalysisResponse).
          properties:
            overallDirection:
              type: string
              enum: [improving, worsening, stable]
              description: Long-term trend direction.
            recentDirection:
              type: string
              enum: [improving, worsening, stable]
              description: Short-term trend direction.
            isHot:
              type: boolean
              description: True when the player is on a recent hot streak.
            isCold:
              type: boolean
              description: True when the player is on a recent cold streak.
        consistency:
          type: object
          description: Compact consistency summary.
          properties:
            score:
              type: number
              minimum: 0.0
              maximum: 1.0
              description: Normalized consistency score [0, 1].
            label:
              type: string
              description: German-language consistency label.
        readyForRecommendations:
          type: boolean
          description: True when sufficient data is available to generate meaningful recommendations.
        assembledAt:
          type: string
          format: date-time
          description: ISO 8601 timestamp when this context snapshot was assembled.

  parameters:
    playerId:
      name: playerId
      in: path
      required: true
      schema:
        type: string
        format: uuid
      example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"

  responses:
    TooManyRequests:
      description: Rate limit exceeded.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            error:
              code: rate_limit_exceeded
              message: "Too many requests. Please retry after 60 seconds."

    Unauthorized:
      description: Missing or invalid JWT token.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'

    Forbidden:
      description: Authenticated but not authorized — admin privileges required.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'
          example:
            error:
              code: forbidden
              message: "Admin access required."

    PlayerNotFound:
      description: No player found with this ID.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'

    InsufficientHistory:
      description: Player has fewer than 3 posted rounds.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ApiError'

paths:

  /health:
    get:
      tags: [System]
      summary: Health check
      description: |
        Returns the operational status of the API and its dependencies.

        **Public** — no JWT required. Safe for uptime monitors and load balancers.

        Returns `200 OK` when all checks pass, `503 Service Unavailable` when any check fails.
      security: []
      operationId: health.status
      responses:
        '200':
          description: All systems healthy
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResult'
        '503':
          description: One or more checks failed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HealthResult'

  /courses:
    get:
      tags: [Courses]
      summary: Search or list courses
      description: |
        Returns courses from the catalogue.

        **With `?q=` parameter:** case-insensitive substring search on course name.
        **Without `?q=`:** returns all courses, paginated.

        This endpoint is **public** — no JWT required.
        Use it to populate course selectors before the player posts a round.
      security: []
      parameters:
        - name: q
          in: query
          required: false
          schema:
            type: string
          description: Search term. Case-insensitive substring match on course name.
          example: "strandgrün"
        - name: limit
          in: query
          required: false
          schema:
            type: integer
            default: 20
            maximum: 50
          description: Maximum number of results.
        - name: offset
          in: query
          required: false
          schema:
            type: integer
            default: 0
          description: Pagination offset.
      responses:
        "200":
          description: Course list with tees.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseListResponse'
              examples:
                search_result:
                  summary: Search for Falkenstein
                  value:
                    courses:
                      - courseId: "11111111-0005-0000-0000-000000000001"
                        name: "Hamburger Golf-Club e.V. (Falkenstein)"
                        country: "DE"
                        teeSets:
                          - teeSetId: "a1b2c3d4-0001-0000-0000-000000000001"
                            name: "Gelb"
                            ratings:
                              - teeRatingId: "b2c3d4e5-0001-0000-0000-000000000001"
                                ratingCategory: "MEN"
                                holeCount: 18
                                routingType: "FULL_18"
                                courseRating: 71.4
                                slopeRating: 135
                                par: 71
                                isNineHoleTee: false
                                suggestedRoundType: "18hole"
                          - teeSetId: "a1b2c3d4-0002-0000-0000-000000000001"
                            name: "Rot"
                            ratings:
                              - teeRatingId: "b2c3d4e5-0002-0000-0000-000000000001"
                                ratingCategory: "WOMEN"
                                holeCount: 18
                                routingType: "FULL_18"
                                courseRating: 73.2
                                slopeRating: 133
                                par: 71
                                isNineHoleTee: false
                                suggestedRoundType: "18hole"
                                hasHoleData: false
                    total: 1
                    query: "falkenstein"
    post:
      tags: [Courses]
      summary: Create a new course
      description: |
        Adds a new course to the catalogue with one or more TeeSets.

        Use this when a course is missing from the catalogue.
        After creating, use `courseId` + `teeRatingId` when posting a round (Mode A preferred).

        **JWT required** — any authenticated user may create courses to fill catalogue gaps.

        The `teeSets` array must contain at least one TeeSet.
        Each TeeSet must contain at least one rating.
        Optional extended fields (bogeyRating, totalDistance/totalDistanceUnit, ratingAuthority, etc.) are accepted
        but not required — omit them if unavailable.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateCourseRequest'
            examples:
              minimal:
                summary: Minimal — one tee, one rating
                value:
                  name: "Mein Heimplatz"
                  teeSets:
                    - name: "Gelb"
                      ratings:
                        - ratingCategory: "MEN"
                          routingType: "FULL_18"
                          courseRating: 71.5
                          slopeRating: 126
                          par: 72
              gender_split:
                summary: Two gender ratings in one TeeSet
                value:
                  name: "Strandgrün Nordplatz"
                  country: "DE"
                  teeSets:
                    - name: "Gelb"
                      ratings:
                        - ratingCategory: "MEN"
                          routingType: "FULL_18"
                          courseRating: 71.5
                          slopeRating: 126
                          par: 72
                        - ratingCategory: "WOMEN"
                          routingType: "FULL_18"
                          courseRating: 73.2
                          slopeRating: 130
                          par: 73
              multi_tee_full:
                summary: Two TeeSets (Herren/Damen getrennte Abschläge) with extended fields
                value:
                  name: "Hamburger Golf-Club Falkenstein"
                  clubName: "Hamburger Golf-Club e.V."
                  country: "DE"
                  teeSets:
                    - name: "Gelb"
                      sortOrder: 0
                      ratings:
                        - ratingCategory: "MEN"
                          routingType: "FULL_18"
                          courseRating: 71.4
                          slopeRating: 135
                          par: 72
                          bogeyRating: 94.5
                          totalDistance: 6540
                          totalDistanceUnit: "YARDS"
                          ratingAuthority: "DGV"
                          ratedOn: "2023-05-15"
                          frontCourseRating: 35.8
                          frontSlopeRating: 134
                          backCourseRating: 35.6
                          backSlopeRating: 136
                    - name: "Rot"
                      sortOrder: 1
                      ratings:
                        - ratingCategory: "WOMEN"
                          routingType: "FULL_18"
                          courseRating: 73.2
                          slopeRating: 133
                          par: 73
              nine_hole:
                summary: 9-Loch Platz (Vorder 9)
                value:
                  name: "Kurzplatz Nordheide"
                  teeSets:
                    - name: "Gelb"
                      ratings:
                        - ratingCategory: "MEN"
                          routingType: "FRONT_NINE"
                          courseRating: 35.5
                          slopeRating: 121
                          par: 36
      responses:
        "201":
          description: Course created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "400":
          description: Invalid teeSets structure or missing required fields.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "422":
          description: Validation error (e.g. CR/SR/Par out of WHS range).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}:
    get:
      tags: [Courses]
      summary: Get a single course with all tees
      description: |
        Returns full detail for one course, including all available tees.

        Use `teeName` from the tees array when posting a round via Mode A:
        ```json
        POST /v1/rounds
        {
          "adjustedGrossScore": 92,
          "courseId": "11111111-0001-...",
          "teeName": "Gelb",
          "playedAt": "2026-05-17"
        }
        ```

        This endpoint is **public** — no JWT required.
      security: []
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
      responses:
        "200":
          description: Course with all tees.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "404":
          description: Course not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    patch:
      tags: [Courses]
      summary: Update course metadata
      description: |
        Updates mutable fields of an existing course (name, geo data, club name, country).
        All fields except `name` are optional — omit to leave them unchanged.

        **Admin-only** — requires a JWT with `adm=true`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CourseUpdateRequest'
            examples:
              rename_with_geo:
                summary: Update name and geo
                value:
                  name: "Hamburger Golf-Club Falkenstein"
                  city: "Hamburg"
                  latitude: 53.5753
                  longitude: 9.8606
      responses:
        "200":
          description: Course updated.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Course not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    delete:
      tags: [Courses]
      summary: Archive (soft-delete) a course
      description: |
        Sets the course status to `ARCHIVED`. The course is no longer returned in search results
        but existing rounds that reference it remain intact.

        **Admin-only** — requires a JWT with `adm=true`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
      responses:
        "200":
          description: Course archived. Returns the updated course with status ARCHIVED.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Course not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/restore:
    post:
      tags: [Courses]
      summary: Reactivate an archived course
      description: |
        Sets the course status back to `ACTIVE`. The course reappears in search results.

        Can only be called on courses with status `ARCHIVED`. Calling on an already active
        course is a no-op (returns the course unchanged).

        **Admin-only** — requires a JWT with `adm=true`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
      responses:
        "200":
          description: Course reactivated. Returns the updated course with status ACTIVE.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Course not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/purge:
    delete:
      tags: [Courses]
      summary: Permanently delete a course
      description: |
        Hard-deletes the course and all its sub-resources (TeeSets, TeeRatings, Holes,
        CourseDataProposals) from the database. This action is irreversible.

        The operation will fail with `409 Conflict` if the course still has pending rounds
        (`pending_rounds.course_id` FK RESTRICT). Resolve or delete all pending rounds first.

        **Admin-only** — requires a JWT with `adm=true`.
        Should only be called on courses with status `ARCHIVED`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
      responses:
        "200":
          description: Course permanently deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  courseId:
                    type: string
                    format: uuid
                  status:
                    type: string
                    example: PURGED
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Course not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "409":
          description: Cannot delete — dependent data (e.g. pending rounds) still exists.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/tee-sets:
    post:
      tags: [Courses]
      summary: Add a TeeSet to a course
      description: |
        Appends a new TeeSet (physical tee marker) with one or more ratings to an existing course.

        **JWT required** — any authenticated user may add missing TeeSets.

        **Rate limit:** 10 requests per hour per user (`course.contribute` policy).
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTeeSetRequest'
            examples:
              add_black_tee:
                summary: Add a black (championship) tee
                value:
                  name: "Schwarz"
                  sortOrder: 0
                  ratings:
                    - ratingCategory: "MEN"
                      routingType: "FULL_18"
                      courseRating: 74.2
                      slopeRating: 141
                      par: 72
      responses:
        "200":
          description: Updated course with the new TeeSet included.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          description: Course not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/tee-sets/{teeSetId}:
    patch:
      tags: [Courses]
      summary: Update TeeSet name and sort order
      description: |
        Corrects the name or display order of an existing TeeSet.

        This is a **data correction** endpoint — use it to fix a mistyped tee name
        or adjust the display sequence. It does not affect the WHS ratings stored
        within the TeeSet.

        **Admin-only** — requires a JWT with `adm=true`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
        - name: teeSetId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "a1b2c3d4-0001-0000-0000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: New name for the TeeSet (e.g. "Gelb", "Rot").
                  example: "Gelb"
                sortOrder:
                  type: integer
                  description: Display order (lower = first). Defaults to 99 if omitted.
                  example: 1
            example:
              name: "Gelb"
              sortOrder: 1
      responses:
        "200":
          description: Updated course with the corrected TeeSet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "400":
          description: Validation error (empty name).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Course or TeeSet not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    delete:
      tags: [Courses]
      summary: Remove a TeeSet from a course
      description: |
        Removes a TeeSet and all its ratings from the course.
        The course must retain at least one TeeSet — removing the last one returns 422.

        **Admin-only** — requires a JWT with `adm=true`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
        - name: teeSetId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "a1b2c3d4-0001-0000-0000-000000000001"
      responses:
        "200":
          description: Updated course with the TeeSet removed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Course or TeeSet not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "422":
          description: Cannot remove the last TeeSet from a course.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/tee-sets/{teeSetId}/ratings:
    post:
      tags: [Courses]
      summary: Add a TeeRating to a TeeSet
      description: |
        Appends a new WHS rating (e.g. for the opposite gender or a composite routing)
        to an existing TeeSet.

        **JWT required** — any authenticated user may add missing ratings.

        **Rate limit:** 10 requests per hour per user (`course.contribute` policy).
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
        - name: teeSetId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "a1b2c3d4-0001-0000-0000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateTeeRatingRequest'
            examples:
              add_women_rating:
                summary: Add women's rating to existing TeeSet
                value:
                  ratingCategory: "WOMEN"
                  routingType: "FULL_18"
                  courseRating: 73.2
                  slopeRating: 133
                  par: 73
      responses:
        "200":
          description: Updated course with the new rating included.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          description: Course or TeeSet not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/tee-sets/{teeSetId}/ratings/{ratingId}:
    patch:
      tags: [Courses]
      summary: Correct a TeeRating (data correction, no versioning)
      description: |
        Updates CR, SR, Par and optional extended fields of an existing TeeRating **in-place**.

        Use this to fix data entry errors (e.g. mistyped Course Rating or Slope Rating).
        The rating ID, category, routing type, validTo, and hole data are preserved.

        **This is NOT a re-rating.** When a national rating authority (e.g. DGV) issues
        officially revised values, use `POST .../ratings/{ratingId}/version` instead,
        which preserves the historical record with a `validTo` close date.

        **Admin-only** — requires a JWT with `adm=true`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
        - name: teeSetId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "a1b2c3d4-0001-0000-0000-000000000001"
        - name: ratingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b2c3d4e5-0001-0000-0000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [courseRating, slopeRating, par]
              properties:
                courseRating:
                  type: number
                  format: float
                  description: Course Rating (WHS). 18H range 55.0–77.0; 9H range 27.5–38.5.
                  example: 72.4
                slopeRating:
                  type: integer
                  description: Slope Rating (WHS). Range 55–155.
                  example: 132
                par:
                  type: integer
                  description: Par of the course (or nine) covered by this rating.
                  example: 72
                bogeyRating:
                  type: number
                  format: float
                  description: Bogey Rating. Optional.
                  example: 92.4
                totalDistance:
                  type: integer
                  description: Total course length. Specify unit via `totalDistanceUnit`. Optional.
                  example: 6250
                totalDistanceUnit:
                  type: string
                  enum: [YARDS, METERS]
                  description: Unit for `totalDistance`. Required when `totalDistance` is provided. Optional.
                  example: "YARDS"
                frontCourseRating:
                  type: number
                  format: float
                  description: Course Rating for front 9. Optional.
                  example: 36.1
                frontSlopeRating:
                  type: integer
                  description: Slope Rating for front 9. Optional.
                  example: 131
                frontBogeyRating:
                  type: number
                  format: float
                  example: 46.3
                backCourseRating:
                  type: number
                  format: float
                  description: Course Rating for back 9. Optional.
                  example: 36.3
                backSlopeRating:
                  type: integer
                  description: Slope Rating for back 9. Optional.
                  example: 133
                backBogeyRating:
                  type: number
                  format: float
                  example: 46.1
                ratingAuthority:
                  type: string
                  description: Issuing rating authority (e.g. "DGV"). Optional.
                  example: "DGV"
                ratedOn:
                  type: string
                  format: date
                  description: Date of the official rating. Optional.
                  example: "2023-04-15"
                validFrom:
                  type: string
                  format: date
                  description: Effective date of this rating. Optional — preserves existing value if omitted.
                  example: "2023-05-01"
            example:
              courseRating: 72.4
              slopeRating: 132
              par: 72
      responses:
        "200":
          description: Updated course with the corrected TeeRating.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "400":
          description: Validation error (invalid CR/SR/Par values).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Course, TeeSet, or rating not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
    delete:
      tags: [Courses]
      summary: Remove a TeeRating from a TeeSet
      description: |
        Removes one WHS rating from a TeeSet.
        The TeeSet must retain at least one rating — removing the last one returns 422.

        **Admin-only** — requires a JWT with `adm=true`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
        - name: teeSetId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "a1b2c3d4-0001-0000-0000-000000000001"
        - name: ratingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b2c3d4e5-0001-0000-0000-000000000001"
      responses:
        "200":
          description: Updated course with the rating removed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Course, TeeSet, or rating not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "422":
          description: Cannot remove the last rating from a TeeSet.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/tee-sets/{teeSetId}/ratings/{ratingId}/version:
    post:
      tags: [Courses]
      summary: Re-rate a TeeRating (temporal versioning)
      description: |
        Closes the current TeeRating on `reratingDate` and appends a successor
        with the new CR/SR/Par values effective from `reratingDate`.

        The old rating remains visible in the response with `validTo` set.
        The successor has `validFrom = reratingDate` and `validTo = null`.

        This implements WHS temporal audit semantics: rating history is preserved
        rather than overwritten.

        **Admin-only** — requires a JWT with `adm=true`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
        - name: teeSetId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "a1b2c3d4-0001-0000-0000-000000000001"
        - name: ratingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b2c3d4e5-0001-0000-0000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [reratingDate, courseRating, slopeRating, par]
              properties:
                reratingDate:
                  type: string
                  format: date
                  description: ISO date on which the old rating closes and the new one opens.
                  example: "2024-05-01"
                courseRating:
                  type: number
                  format: float
                  description: New course rating for the successor.
                  example: 71.8
                slopeRating:
                  type: integer
                  description: New slope rating for the successor (55–155).
                  example: 130
                par:
                  type: integer
                  description: New par for the successor routing.
                  example: 71
                ratingAuthority:
                  type: string
                  nullable: true
                  description: Authority that issued the re-rating (optional).
                  example: "DGV"
                ratedOn:
                  type: string
                  format: date
                  nullable: true
                  description: Date of the official rating event (optional).
                  example: "2024-04-15"
      responses:
        "200":
          description: Updated course — old rating has validTo set, successor appended.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Course, TeeSet, or rating not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "422":
          description: reratingDate is missing or malformed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/tee-sets/{teeSetId}/ratings/{ratingId}/holes:
    put:
      tags: [Courses]
      summary: Replace all holes for a TeeRating
      description: |
        Bulk-replaces the complete hole definitions for a TeeRating. Idempotent.

        Provide all holes for the routing type:
        - FULL_18 / COMPOSITE: 18 holes (holeNumber 1–18)
        - FRONT_NINE: 9 holes (holeNumber 1–9)
        - BACK_NINE: 9 holes (holeNumber 10–18)

        After this call `hasHoleData` becomes `true` for the affected rating,
        enabling hole-by-hole score entry (Path 1) for rounds on this tee.

        **Admin-only** — requires a JWT with `adm=true`.
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "11111111-0001-0000-0000-000000000001"
        - name: teeSetId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "a1b2c3d4-0001-0000-0000-000000000001"
        - name: ratingId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b2c3d4e5-0001-0000-0000-000000000001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetHolesRequest'
            examples:
              full_18:
                summary: 18-hole scorecard (abbreviated)
                value:
                  holes:
                    - holeNumber: 1
                      par: 4
                      strokeIndex: 7
                      yardage: 380
                    - holeNumber: 2
                      par: 3
                      strokeIndex: 15
                      yardage: 168
      responses:
        "200":
          description: Updated course with the new hole data included.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "400":
          description: Validation error (wrong hole count for routing type, duplicate holeNumber, etc.).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          description: Course, TeeSet, or rating not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/import:
    post:
      tags: [Courses]
      summary: Import a course from GolfCourseAPI
      description: |
        Fetches a course by its GolfCourseAPI numeric ID and upserts it into the local catalogue.
        Idempotent: re-importing the same external ID updates the existing record.

        **Admin-only** — requires a JWT with `adm=true`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [courseId]
              properties:
                courseId:
                  type: string
                  description: GolfCourseAPI numeric course ID.
                  example: "1234"
      responses:
        "201":
          description: Course imported or updated successfully.
          content:
            application/json:
              schema:
                type: object
                properties:
                  imported:
                    type: boolean
                    example: true
        "400":
          description: Missing or invalid courseId.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'


  /courses/{courseId}/tee-sets/{teeSetId}/ratings/{ratingId}/community-holes:
    post:
      tags: [Courses]
      summary: Contribute hole data for a TeeRating (community)
      description: |
        Any authenticated user may fill the hole definitions for a TeeRating that currently has
        **no holes**. Returns 409 Conflict if holes already exist — use the admin
        `PUT …/holes` endpoint to replace existing data.

        **Requires JWT** — no admin claim needed. (ADR-018)

        **Rate limit:** 10 requests per hour per user (`course.contribute` policy).
      security:
        - bearerAuth: []
      parameters:
        - name: courseId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: teeSetId
          in: path
          required: true
          schema: { type: string, format: uuid }
        - name: ratingId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SetHolesRequest'
      responses:
        "200":
          description: Holes saved; updated course returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseOutput'
        "400":
          description: Validation error.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          description: Course, TeeSet, or TeeRating not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "409":
          description: Hole data already exists for this TeeRating. Use the admin PUT endpoint.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/verify:
    post:
      tags: [Courses]
      summary: Verify (community-vouch) a course
      description: |
        Any authenticated user can cast one verification vote for a course.
        Once a **Draft** course accumulates **3 unique verifications**, it is
        automatically promoted to `Active`. (ADR-033)

        Verifying an already-Active course is accepted (idempotent 200) but
        triggers no status change.

        **Rate limit:** `course.verify` policy.
      operationId: verifyCourse
      security:
        - bearerAuth: []
      parameters:
        - name: courseId
          in: path
          required: true
          schema:
            type: string
            format: uuid
      responses:
        '200':
          description: Vote recorded (or already cast by this user).
          content:
            application/json:
              schema:
                type: object
                required: [verificationCount, alreadyVerified, promoted]
                properties:
                  verificationCount:
                    type: integer
                    description: Total unique verifications for this course.
                  alreadyVerified:
                    type: boolean
                    description: True when this user had already verified the course before this request.
                  promoted:
                    type: boolean
                    description: True when this vote triggered automatic promotion from Draft to Active.
        '401':
          description: Unauthorized.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '404':
          description: Course not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /courses/{courseId}/proposals:
    post:
      tags: [Courses]
      summary: Submit a course data correction proposal
      description: |
        Any authenticated user may submit a proposal to correct course metadata or
        tee rating values. Proposals are reviewed and applied (or rejected) by an admin.
        (ADR-019)

        **Rate limit:** 5 requests per hour per user (`course.propose` policy).
      security:
        - bearerAuth: []
      parameters:
        - name: courseId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/SubmitProposalRequest'
      responses:
        "201":
          description: Proposal submitted successfully.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseDataProposalOutput'
        "400":
          description: Validation error or invalid proposal type.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          description: Course not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /admin/courses/seed-german:
    post:
      tags: [Admin]
      summary: Import German DGV courses (paginated)
      description: |
        Fetches one page of clubs from handicap-berechnen.de and imports their
        courses as **Draft** entries. (ADR-033)

        - Courses already imported (matched by external identity) are skipped.
        - Manually-created courses with the same club name + city are *linked*
          (external identity added, tee data preserved) and not duplicated.
        - Promotes to Active only via community verification (3 votes).

        Call repeatedly with increasing `offset` until `has_more` is `false`.

        **Admin-only.**
      operationId: seedGermanCourses
      security:
        - bearerAuth: []
      parameters:
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
          description: Zero-based index of the first club to process.
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 50
          description: Number of clubs to process in this batch.
        - name: dry_run
          in: query
          schema:
            type: boolean
            default: false
          description: When true, parse and detect duplicates but write nothing.
      responses:
        '200':
          description: Batch complete.
          content:
            application/json:
              schema:
                type: object
                required: [offset, limit, total_clubs, processed, imported, linked, skipped, errors, has_more, dry_run]
                properties:
                  offset:       { type: integer }
                  limit:        { type: integer }
                  total_clubs:  { type: integer }
                  processed:    { type: integer }
                  imported:     { type: integer, description: New Draft courses created. }
                  linked:       { type: integer, description: Existing courses linked to source. }
                  skipped:      { type: integer, description: Already-imported courses skipped. }
                  no_data:      { type: integer, description: Clubs with no usable course data. }
                  errors:       { type: integer }
                  has_more:     { type: boolean }
                  dry_run:      { type: boolean }
                  clubs:
                    type: array
                    items:
                      type: object
                      properties:
                        slug:     { type: string }
                        name:     { type: string }
                        imported: { type: integer }
                        linked:   { type: integer }
                        skipped:  { type: integer }
                        errors:   { type: integer }
        '401':
          description: Unauthorized.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '403':
          description: Admin access required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /admin/courses/{keepId}/merge/{duplicateId}:
    post:
      tags: [Admin]
      summary: Merge duplicate course into original
      description: |
        Merges a duplicate course (typically created by the seeder when the
        name-matching heuristic missed an existing manual entry) into the
        course that should be retained.

        **What happens:**
        - All `course_external_identities` of the duplicate are re-pointed to
          `keepId` — future seeder runs will recognise the course as already present.
        - Any `pending_rounds` referencing the duplicate are re-pointed to `keepId`.
        - The duplicate is permanently deleted (cascades tee sets, tee ratings,
          verifications, and proposals).

        **What is NOT affected:**
        - `score_records` (posted rounds) — these carry a fully denormalized
          `CourseSnapshot` with no FK to `courses.id` and are never touched.

        **Admin-only.**
      operationId: mergeCourseDuplicate
      security:
        - bearerAuth: []
      parameters:
        - name: keepId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: ID of the course to keep (the original with existing rounds).
        - name: duplicateId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          description: ID of the duplicate to delete (typically the seeder-created one).
      responses:
        '200':
          description: Merge complete.
          content:
            application/json:
              schema:
                type: object
                required: [keep_id, duplicate_id, moved_pending_rounds, filled_fields]
                properties:
                  keep_id:
                    type: string
                    format: uuid
                  duplicate_id:
                    type: string
                    format: uuid
                  moved_pending_rounds:
                    type: integer
                    description: Number of pending rounds re-pointed to keepId.
                  filled_fields:
                    type: array
                    items:
                      type: string
                    description: >
                      Metadata fields that were null on keep and filled from
                      the duplicate (e.g. ["latitude","longitude","city"]).
        '400':
          description: keepId and duplicateId are identical.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '404':
          description: keepId or duplicateId not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '401':
          description: Unauthorized.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        '403':
          description: Admin access required.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /admin/proposals:
    get:
      tags: [Admin]
      summary: List course data proposals
      description: |
        Returns paginated list of proposals. Optionally filtered by status.
        **Admin-only.**
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          required: false
          schema:
            type: string
            enum: [PENDING, APPROVED, REJECTED]
        - name: limit
          in: query
          required: false
          schema: { type: integer, default: 50 }
        - name: offset
          in: query
          required: false
          schema: { type: integer, default: 0 }
      responses:
        "200":
          description: Paginated list of proposals.
          content:
            application/json:
              schema:
                type: object
                properties:
                  proposals:
                    type: array
                    items:
                      $ref: '#/components/schemas/CourseDataProposalOutput'
                  total:
                    type: integer
                  limit:
                    type: integer
                  offset:
                    type: integer
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'

  /admin/proposals/{proposalId}/approve:
    post:
      tags: [Admin]
      summary: Approve a course data proposal
      description: |
        Applies the proposal's payload to the target Course aggregate and marks
        the proposal as APPROVED. **Admin-only.**
      security:
        - bearerAuth: []
      parameters:
        - name: proposalId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reviewNote:
                  type: string
                  example: "Verified against official DGV scorecard."
                setValidTo:
                  type: boolean
                  default: false
                  description: |
                    false (default): in-place correction — data was always wrong; triggers differential recalculation.
                    true: versioning — course changed; closes old TeeRating with valid_to=today, creates successor.
      responses:
        "200":
          description: Proposal approved and changes applied.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseDataProposalOutput'
        "400":
          description: Proposal is not in PENDING status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Proposal not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /admin/proposals/{proposalId}/reject:
    post:
      tags: [Admin]
      summary: Reject a course data proposal
      description: |
        Marks the proposal as REJECTED without applying changes. **Admin-only.**
      security:
        - bearerAuth: []
      parameters:
        - name: proposalId
          in: path
          required: true
          schema: { type: string, format: uuid }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                reviewNote:
                  type: string
                  example: "Values already correct in our database."
      responses:
        "200":
          description: Proposal rejected.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CourseDataProposalOutput'
        "400":
          description: Proposal is not in PENDING status.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          description: Proposal not found.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /auth/register:
    post:
      tags: [Authentication]
      summary: Register a new player account
      description: |
        Returns the same neutral 202 response regardless of whether the email address
        is new or already registered — preventing email enumeration attacks.
        No JWT is issued. The user must log in via `POST /auth/login` after registration.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, email, password]
              properties:
                name:
                  type: string
                  example: "Alice"
                email:
                  type: string
                  format: email
                  example: "alice@example.com"
                password:
                  type: string
                  minLength: 8
                  example: "my-secure-password"
      responses:
        "202":
          description: Request accepted. If the email was not yet registered, the account has been created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RegisterResponse'
        "422":
          description: Validation error (invalid email format, password too short, empty name).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'

  /auth/login:
    post:
      tags: [Authentication]
      summary: Login and receive a JWT token (or MFA challenge)
      description: |
        **Step 1 of authentication.** Returns either a full token pair (no MFA) or
        an MFA challenge (`mfa_required: true`) requiring step 2 via `/auth/mfa/verify`.

        Admin accounts always require MFA. If an admin has not enrolled TOTP yet, login
        returns 200 with `status: mfa_setup_required` and a `setup_token` (valid 10 min).
        Use the setup_token as Bearer token for `GET /me/mfa/totp/setup` and
        `POST /me/mfa/totp/verify` to complete enrollment. After enrollment those endpoints
        return a `partial_token` to immediately proceed to `POST /auth/mfa/verify`.
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email, password]
              properties:
                email:
                  type: string
                  format: email
                  example: "alice@example.com"
                password:
                  type: string
                  example: "my-secure-password"
      responses:
        "200":
          description: Login successful, MFA challenge, or TOTP setup required.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AuthResponse'
                  - $ref: '#/components/schemas/MfaRequiredResponse'
                  - $ref: '#/components/schemas/MfaSetupRequiredResponse'
              examples:
                noMfa:
                  summary: Direct login (no MFA enrolled)
                  value:
                    token: "eyJhbGciOiJIUzI1NiJ9..."
                    expiresAt: "2026-05-29T15:30:00+00:00"
                    playerId: "abc123"
                    playerName: "Alice"
                    email: "alice@example.com"
                    refreshToken: "a3f8c1d2e4b5..."
                    refreshTokenExpiresIn: 2592000
                mfaRequired:
                  summary: MFA challenge (TOTP required)
                  value:
                    mfa_required: true
                    mfa_methods: [totp]
                    partial_token: "eyJhbGciOiJIUzI1NiJ9..."
                mfaSetupRequired:
                  summary: Admin has no TOTP yet — must enroll before proceeding
                  value:
                    status: mfa_setup_required
                    setup_token: "eyJhbGciOiJIUzI1NiJ9..."
        "401":
          description: Invalid credentials or account locked.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              examples:
                invalid_credentials:
                  summary: Invalid email or password
                  value:
                    error:
                      code: unauthorized
                      message: "Invalid email or password."
                account_locked_temporary:
                  summary: Account temporarily locked after too many failed attempts
                  value:
                    error:
                      code: account_locked
                      message: "Your account is temporarily locked due to too many failed login attempts. Try again after 2026-05-26T10:15:00+00:00."
                account_locked_permanent:
                  summary: Account permanently locked (>=50 failed attempts)
                  value:
                    error:
                      code: account_locked
                      message: "Your account has been permanently locked due to too many failed login attempts. Please contact support."

  /auth/verify-email:
    post:
      tags: [Authentication]
      summary: Verify email address with a one-time token
      description: |
        Consumes a single-use verification token that was sent to the user's email
        address after registration. The token is valid for 24 hours.

        On success, `email_verified_at` is set on the user record.
        Login is not blocked for unverified accounts at this stage (Sec Phase 2).
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token]
              properties:
                token:
                  type: string
                  minLength: 64
                  maxLength: 64
                  example: "a3f8c1d2e4b5..."
      responses:
        "200":
          description: Email successfully verified.
          content:
            application/json:
              schema:
                type: object
                required: [message]
                properties:
                  message:
                    type: string
                    example: "Email address successfully verified. You can now log in."
        "400":
          description: Token invalid or expired.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              examples:
                token_invalid:
                  summary: Token not found or already used
                  value:
                    error:
                      code: email_verification_failed
                      message: "The verification link is invalid or has already been used."
                token_expired:
                  summary: Token expired (TTL 24h)
                  value:
                    error:
                      code: email_verification_failed
                      message: "The verification link has expired. Please register again to receive a new link."

  /auth/token/refresh:
    post:
      tags: [Authentication]
      summary: Exchange refresh token for a new access + refresh token pair
      description: |
        Implements token rotation: each call invalidates the submitted refresh token
        and issues a new pair. Submitting a previously consumed token triggers replay
        detection and invalidates ALL sessions for that user.
      security: []
      parameters:
        - in: header
          name: Authorization
          required: true
          schema:
            type: string
          description: "Bearer <refresh_token>"
          example: "Bearer a3f8c1d2e4b5..."
      responses:
        "200":
          description: New token pair issued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TokenRefreshResponse'
        "401":
          description: Refresh token invalid, expired, revoked, or replay detected.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              examples:
                invalid_refresh_token:
                  summary: Token not found, expired, or revoked
                  value:
                    error:
                      code: invalid_refresh_token
                      message: "Refresh token has expired. Please log in again."
                token_reuse_detected:
                  summary: Previously consumed token reused (all sessions invalidated)
                  value:
                    error:
                      code: token_reuse_detected
                      message: "Security anomaly detected. All sessions have been invalidated."
        "429":
          $ref: '#/components/responses/TooManyRequests'

  /auth/logout:
    post:
      tags: [Authentication]
      summary: Revoke the current refresh token session
      description: |
        Invalidates the submitted refresh token. The corresponding access JWT
        remains valid until its natural expiry (15 min). Idempotent — submitting
        an already-revoked or unknown token returns 204 without error.
      security: []
      parameters:
        - in: header
          name: Authorization
          required: true
          schema:
            type: string
          description: "Bearer <refresh_token>"
          example: "Bearer a3f8c1d2e4b5..."
      responses:
        "204":
          description: Session revoked (or was already invalid).
        "429":
          $ref: '#/components/responses/TooManyRequests'

  /auth/logout-all:
    post:
      tags: [Authentication]
      summary: Revoke all refresh token sessions for the authenticated user
      description: |
        Invalidates all active refresh tokens for the user identified by the
        access JWT. Useful after a password change or suspected account compromise.
      responses:
        "204":
          description: All sessions revoked.
        "401":
          $ref: '#/components/responses/Unauthorized'

  /auth/mfa/verify:
    post:
      tags: [Authentication]
      summary: Complete MFA login step (step 2 of two-factor login)
      description: |
        After a successful password login where MFA is required, the `/auth/login` response
        contains a `partial_token` (TTL: 5 minutes) instead of a full access token.

        This endpoint validates the TOTP code or backup code and completes login by
        issuing a full access token + refresh token pair.

        **Methods:**
        - `totp` — 6-digit time-based code from authenticator app
        - `backup_code` — 8-character one-time backup code (issued at enrolment)
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [partial_token, method, code]
              properties:
                partial_token:
                  type: string
                  description: Short-lived partial JWT from step 1
                method:
                  type: string
                  enum: [totp, backup_code]
                code:
                  type: string
                  description: 6-digit TOTP code or 8-char backup code
            example:
              partial_token: "eyJhbGciOiJIUzI1NiJ9..."
              method: totp
              code: "123456"
      responses:
        "200":
          description: MFA verified — full token pair issued.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthResponse'
        "401":
          description: Invalid or expired partial token / MFA code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalidPartialToken:
                  summary: Partial token invalid or expired
                  value:
                    error:
                      code: invalid_partial_token
                      message: "Invalid or expired MFA session token."
                mfaCodeInvalid:
                  summary: Wrong TOTP or backup code
                  value:
                    error:
                      code: mfa_code_invalid
                      message: "Invalid MFA code. Please try again."
        "429":
          $ref: '#/components/responses/TooManyRequests'

  /auth/forgot-password:
    post:
      tags: [Authentication]
      summary: Request a password reset link
      description: |
        Sends a password reset link to the given email address if it belongs to a
        **verified** account (`email_verified_at` is set).

        This endpoint always returns `202 Accepted` regardless of whether the email
        exists or is verified — no information about account existence is disclosed
        (email enumeration protection).

        The reset link contains a single-use token with a **1-hour TTL**. Requesting
        a new link invalidates any previously issued token for the same account.

        Rate-limited: 3 requests per 15 minutes per IP.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email:
                  type: string
                  format: email
            example:
              email: user@example.com
      responses:
        "202":
          description: Request accepted. A reset link has been sent if the account exists and is verified.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
              example:
                message: "If this email address belongs to a verified account, a password reset link has been sent."
        "422":
          $ref: '#/components/responses/InvalidInput'
        "429":
          $ref: '#/components/responses/TooManyRequests'

  /auth/reset-password:
    post:
      tags: [Authentication]
      summary: Reset password using a valid reset token
      description: |
        Applies a new password using the reset token from the email link.

        On success:
        - The user's password is updated.
        - All active refresh token sessions are revoked (re-login required everywhere).
        - Account lockout state is cleared (ownership proven via email).
        - The token is consumed and cannot be reused.

        Returns `400` with code `password_reset_failed` if the token is invalid or expired.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [token, password]
              properties:
                token:
                  type: string
                  description: 64-character hex token from the reset email link
                  minLength: 64
                  maxLength: 64
                password:
                  type: string
                  description: New password (minimum 8 characters)
                  minLength: 8
            example:
              token: "a3f8c1d2e4b5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
              password: "NewSecurePass1!"
      responses:
        "200":
          description: Password successfully reset.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message:
                    type: string
              example:
                message: "Password successfully reset. Please log in with your new password."
        "400":
          description: Invalid or expired reset token.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
              examples:
                token_not_found:
                  summary: Token invalid or already used
                  value:
                    error:
                      code: password_reset_failed
                      message: "The password reset link is invalid or has already been used."
                token_expired:
                  summary: Token expired
                  value:
                    error:
                      code: password_reset_failed
                      message: "The password reset link has expired. Please request a new one."
        "422":
          $ref: '#/components/responses/InvalidInput'

  /me/mfa/totp/setup:
    get:
      tags: [MFA]
      summary: Initiate TOTP enrolment — generate secret and QR URI
      description: |
        Generates a new TOTP secret and returns the provisioning URI for QR-code display.
        The returned secret is never stored in plain text. The enrolment is not active
        until confirmed via `POST /me/mfa/totp/verify`.

        Calling this again while an unverified setup exists replaces the previous setup.
        Calling while already enrolled returns a 500 error.

        Accepts both a regular access JWT **and** the `setup_token` from a
        `mfa_setup_required` login response (admin bootstrap flow).
      security:
        - bearerAuth: []
      responses:
        "200":
          description: TOTP setup initiated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  secret:
                    type: string
                    description: Base32-encoded TOTP secret (display to user for manual entry)
                  qr_uri:
                    type: string
                    description: "otpauth:// URI for QR code generation"
              example:
                secret: JBSWY3DPEHPK3PXP
                qr_uri: "otpauth://totp/Handicap%20Engine%3Auser%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=Handicap%20Engine"
        "401":
          $ref: '#/components/responses/Unauthorized'

  /me/mfa/totp/verify:
    post:
      tags: [MFA]
      summary: Complete TOTP enrolment with first code verification
      description: |
        Confirms TOTP enrolment by verifying the first code. On success, TOTP becomes
        active for this account and the login flow will require MFA from this point on.

        The response includes 10 one-time backup codes — shown **exactly once**.
        The user must store them securely.

        When called with a `setup_token` (admin bootstrap flow), the response additionally
        contains `partial_token` and `status: "mfa_required"`. Use the partial_token
        immediately with `POST /auth/mfa/verify` to complete login.

        Accepts both a regular access JWT **and** the `setup_token` from a
        `mfa_setup_required` login response.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: 6-digit TOTP code from authenticator app
            example:
              code: "123456"
      responses:
        "200":
          description: Enrolment complete. Backup codes shown exactly once.
          content:
            application/json:
              schema:
                type: object
                properties:
                  backup_codes:
                    type: array
                    items:
                      type: string
                    description: "10 one-time 8-character backup codes. Store securely — not shown again."
                  partial_token:
                    type: string
                    description: "Only present when called with a setup_token (admin bootstrap). Use with POST /auth/mfa/verify."
                  status:
                    type: string
                    enum: [mfa_required]
                    description: "Only present when partial_token is included."
              examples:
                standard:
                  summary: Regular enrollment (full JWT used)
                  value:
                    backup_codes: [ABCD1234, EFGH5678, IJKL9012]
                adminBootstrap:
                  summary: Admin bootstrap (setup_token used — contains partial_token)
                  value:
                    backup_codes: [ABCD1234, EFGH5678, IJKL9012]
                    partial_token: "eyJhbGciOiJIUzI1NiJ9..."
                    status: mfa_required
        "401":
          description: Invalid TOTP code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              example:
                error:
                  code: mfa_code_invalid
                  message: "Invalid MFA code. Please try again."

  /me/mfa/totp:
    delete:
      tags: [MFA]
      summary: Disable TOTP for the authenticated user
      description: |
        Revokes the active TOTP credential after verifying the current TOTP code.
        After this, the account no longer requires MFA at login.

        Admin accounts will be blocked from logging in until MFA is re-enrolled
        (GOVERNANCE.md §2 — MFA mandatory for admin accounts).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code:
                  type: string
                  description: Current TOTP code for confirmation
            example:
              code: "123456"
      responses:
        "204":
          description: TOTP disabled.
        "401":
          description: Invalid TOTP code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /me:
    get:
      tags: [Identity]
      summary: Get current player state
      description: |
        Safe to call immediately after login. Never throws for new players.
        Check `readyForInsights` — if false, show the `onboarding` block.
      responses:
        "200":
          description: Player state.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/MeResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'

  /rounds:
    post:
      tags: [Rounds]
      summary: Post a new round
      description: |
        The player ID is resolved from the JWT — do not include it in the body.

        **Two possible success outcomes (both return 201):**

        1. **`RoundResponse`** — the round was fully calculated and added to the WHS pool.
           Returned for all input modes when stroke index data is available.

        2. **`PendingRoundAccepted`** — only for Path 1 (hole-by-hole) when the selected
           tee rating has no stroke index data yet. The round is saved as a pending round
           and will be promoted automatically once hole data is contributed
           (community holes or admin). Check `status === 'pending_course_data'` to detect this.

        Discriminate the two variants by the `status` field:
        - `status` absent → `RoundResponse` (official round)
        - `status === 'pending_course_data'` → `PendingRoundAccepted`
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PostRoundRequest'
      responses:
        "201":
          description: |
            Round recorded (official) or accepted as pending (awaiting course data).
            Discriminate by the `status` field — see endpoint description.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/RoundResponse'
                  - $ref: '#/components/schemas/PendingRoundAccepted'
              examples:
                official:
                  summary: Official round (immediate differential)
                  value:
                    roundId: "424017bf-e262-46e5-85a6-e9b8ab39232f"
                    playerId: "player-uuid"
                    adjustedGrossScore: 92
                    scoreDifferential: 18.3
                    courseRating: 72.0
                    slopeRating: 130
                    par: 72
                    playedAt: "2026-05-17"
                    feedback:
                      message: "Runde gespeichert."
                      handicapIndex: 14.2
                pending:
                  summary: Pending round (SI data missing)
                  value:
                    roundId: "a1b2c3d4-e5f6-0000-0000-000000000001"
                    playerId: "player-uuid"
                    playedAt: "2026-05-17"
                    roundType: "18hole"
                    status: "pending_course_data"
                    feedback:
                      message: "Runde gespeichert. Wird automatisch berechnet, sobald Lochdaten für diesen Abschlag hinterlegt sind."
        "401":
          $ref: '#/components/responses/Unauthorized'
        "422":
          description: |
            Validation error — golfer-readable message included.

            Common causes:
            - Score, course rating, or slope rating out of WHS-allowed range.
            - `adjustedGrossScore`, `holeScores`, and `stablefordPoints` all absent.
            - 9-hole round submitted without a known Handicap Index (no history, no seed).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
              examples:
                invalid_score:
                  summary: Score out of range
                  value:
                    error:
                      code: invalid_input
                      message: "Score (5) must be between 18 and 270."
                nine_hole_no_index:
                  summary: 9-hole round without known Handicap Index
                  value:
                    error:
                      code: invalid_input
                      message: "Eine 9-Loch-Runde kann nur eingetragen werden, wenn ein Handicap-Index bekannt ist. Bitte setze zunächst einen Handicap-Startwert in deinem Profil oder trage eine 18-Loch-Runde ein."

  /rounds/{roundId}:
    patch:
      tags: [Rounds]
      summary: Correct a round (score and/or date)
      description: |
        Corrects a posted round. The round ID is preserved. The score differential
        is recalculated automatically from the corrected input and the original
        course snapshot, guaranteeing WHS correctness (ADR-012, ADR-024).

        **Input-mode-aware correction (ADR-024):**
        The API always uses the original input mode — you cannot switch modes
        during an edit (e.g. a HoleByHole round cannot be corrected via AGS).
        - Rounds originally entered **hole-by-hole**: provide `holeScores` (array of 18 integers).
        - Rounds originally entered via **Stableford**: provide `stablefordPoints`.
        - Rounds originally entered via **direct AGS**: provide `adjustedGrossScore`.

        **Constraints:**
        - At least one of `adjustedGrossScore`, `holeScores`, `stablefordPoints`, or `playedAt` must be provided.
        - Score correction of any kind is **not** supported for nine-hole rounds — the
          differential depends on the Expected Score from the WHS table at time of play
          and cannot be recalculated retroactively. Date correction (`playedAt`) is
          always supported for all round types.
        - Course, tee, and round type changes require delete + re-post.

        Ownership is enforced — you can only correct your own rounds.
      parameters:
        - name: roundId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "424017bf-e262-46e5-85a6-e9b8ab39232f"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                adjustedGrossScore:
                  type: integer
                  minimum: 18
                  maximum: 270
                  description: Corrected AGS. For `direct_ags` mode rounds only. Not supported for 9-hole rounds.
                  example: 84
                holeScores:
                  type: array
                  items:
                    type: integer
                    minimum: 1
                    maximum: 20
                  minItems: 18
                  maxItems: 18
                  description: Corrected hole scores (18 values). For `hole_by_hole` mode rounds only. NDB cap re-applied using original courseHandicap and SI data. Not supported for 9-hole rounds.
                  example: [5, 4, 6, 3, 5, 4, 7, 5, 4, 6, 4, 5, 3, 5, 4, 6, 5, 4]
                stablefordPoints:
                  type: integer
                  minimum: 0
                  maximum: 200
                  description: Corrected Stableford points. For `stableford` mode rounds only. AGS back-calculated from original courseHandicap and par. Not supported for 9-hole rounds.
                  example: 32
                playedAt:
                  type: string
                  format: date
                  description: Corrected play date (YYYY-MM-DD). Supported for all round types and input modes.
                  example: "2026-05-10"
            examples:
              holeCorrectionOnly:
                summary: Correct hole scores (hole-by-hole mode)
                value: { holeScores: [5, 4, 6, 3, 5, 4, 7, 5, 4, 6, 4, 5, 3, 5, 4, 6, 5, 4] }
              stablefordCorrection:
                summary: Correct Stableford points
                value: { stablefordPoints: 32 }
              scoreCorrection:
                summary: Correct AGS (direct-ags mode)
                value: { adjustedGrossScore: 84 }
              dateCorrection:
                summary: Correct date only
                value: { playedAt: "2026-05-10" }
              both:
                summary: Correct both AGS and date
                value: { adjustedGrossScore: 84, playedAt: "2026-05-10" }
      responses:
        "200":
          description: Round corrected with before/after impact.
          content:
            application/json:
              schema:
                type: object
                required: [updated, roundId, message]
                properties:
                  updated:
                    type: boolean
                    example: true
                  roundId:
                    type: string
                    format: uuid
                  indexBefore:
                    type: number
                    nullable: true
                  indexAfter:
                    type: number
                    nullable: true
                  delta:
                    type: number
                    nullable: true
                  message:
                    type: string
              example:
                updated: true
                roundId: "424017bf-e262-46e5-85a6-e9b8ab39232f"
                indexBefore: 12.4
                indexAfter: 12.1
                delta: -0.3
                message: "Runde korrigiert. Dein Index verbessert sich von 12.4 auf 12.1."
        "400":
          description: Invalid input or unsupported correction type.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "404":
          description: Round not found or belongs to a different player.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'

    delete:
      tags: [Rounds]
      summary: Delete a round
      description: |
        Permanently deletes a round belonging to the authenticated player.

        Returns the Handicap Index **before** and **after** deletion so the
        client can immediately show the impact.

        Ownership is enforced — you can only delete your own rounds.
        Attempting to delete another player's round returns 404.

        **This action cannot be undone.**
      parameters:
        - name: roundId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "424017bf-e262-46e5-85a6-e9b8ab39232f"
      responses:
        "200":
          description: Round deleted with impact summary.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/DeleteRoundResponse'
              example:
                deleted: true
                roundId: "424017bf-e262-46e5-85a6-e9b8ab39232f"
                indexBefore: 24.3
                indexAfter: 25.1
                delta: 0.8
                message: "Runde gelöscht. Dein Index steigt von 24.3 auf 25.1."
        "404":
          description: Round not found or belongs to a different player.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'


  /pending-rounds/{roundId}:
    delete:
      tags: [Rounds]
      summary: Delete a pending round
      description: |
        Deletes a pending round that is awaiting course data or has failed promotion.

        Ownership is enforced — you can only delete your own pending rounds.
        This action cannot be undone.
      security:
        - bearerAuth: []
      parameters:
        - name: roundId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b2f3c9a0-1234-4567-abcd-ef0123456789"
      responses:
        "200":
          description: Pending round deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted:
                    type: boolean
                    example: true
                  roundId:
                    type: string
                    format: uuid
                  message:
                    type: string
                    example: "Runde gelöscht."
        "404":
          description: Pending round not found or belongs to a different player.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'
    patch:
      tags: [Rounds]
      summary: Update a pending round (date and/or hole scores)
      description: |
        Corrects a pending round that is awaiting hole/SI data for AGS calculation.

        At least one of `playedAt` or `holeScores` must be provided.

        - `playedAt` — corrects the recorded play date (always supported).
        - `holeScores` — replaces the stored hole scores (18 integers). The stored
          `courseHandicap` and `teeRatingId` are immutable (set at submission) and
          are reused when the round is eventually promoted. AGS is not computed here;
          promotion happens automatically when SI data becomes available.

        Ownership is enforced — you can only update your own pending rounds.
      security:
        - bearerAuth: []
      parameters:
        - name: roundId
          in: path
          required: true
          schema:
            type: string
            format: uuid
          example: "b2f3c9a0-1234-4567-abcd-ef0123456789"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                playedAt:
                  type: string
                  format: date
                  description: Corrected play date (YYYY-MM-DD).
                  example: "2026-05-10"
                holeScores:
                  type: array
                  items:
                    type: integer
                    minimum: 1
                    maximum: 20
                  minItems: 18
                  maxItems: 18
                  description: Corrected gross scores per hole (18 values). Stored for NDB calculation at promotion time.
                  example: [5, 4, 6, 3, 5, 4, 7, 5, 4, 6, 4, 5, 3, 5, 4, 6, 5, 4]
            examples:
              dateOnly:
                summary: Correct date only
                value: { playedAt: "2026-05-10" }
              holeScoresOnly:
                summary: Correct hole scores only
                value: { holeScores: [5, 4, 6, 3, 5, 4, 7, 5, 4, 6, 4, 5, 3, 5, 4, 6, 5, 4] }
              both:
                summary: Correct both date and hole scores
                value: { playedAt: "2026-05-10", holeScores: [5, 4, 6, 3, 5, 4, 7, 5, 4, 6, 4, 5, 3, 5, 4, 6, 5, 4] }
      responses:
        "200":
          description: Pending round updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated:
                    type: boolean
                    example: true
                  roundId:
                    type: string
                    format: uuid
                  message:
                    type: string
                    example: "Datum aktualisiert."
        "404":
          description: Pending round not found or belongs to a different player.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ApiError'
        "401":
          $ref: '#/components/responses/Unauthorized'

  /players/{playerId}/rounds:
    get:
      tags: [Rounds]
      summary: Get round list with course context
      description: |
        Returns a paginated list of all rounds for the player, newest first.

        Each round includes:
        - Course name (from snapshot)
        - Score, differential, toPar
        - `relativeToIndex` — how this round compares to the current effective index
        - `usedInCalculation` — whether it counts toward the current Handicap Index

        Use `?course=` to filter by course name (case-insensitive substring match).

        Works with bootstrapped players — uses seeded index as reference when
        fewer than 3 WHS rounds are available.

        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
        Requests for a foreign player ID are rejected with `403 Forbidden`.
      parameters:
        - name: playerId
          in: path
          required: true
          schema:
            type: string
            format: uuid
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            maximum: 50
        - name: offset
          in: query
          schema:
            type: integer
            default: 0
        - name: course
          in: query
          required: false
          schema:
            type: string
          description: Case-insensitive substring filter on course name.
          example: "Strandgrün"
      responses:
        "200":
          description: Paginated round list with course context.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RoundListResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/PlayerNotFound'


  /me/handicap-seed:
    post:
      tags: [Identity]
      summary: Set bootstrap Handicap Index
      description: |
        Sets or replaces the player's starting Handicap Index without requiring
        historical rounds. This is the primary on-ramp for golfers who already
        have an official handicap but don't want to re-enter all historical rounds.

        After seeding, the player can immediately use simulation and relative
        performance analysis.

        Once the player has ≥ 3 real rounds, `GET /players/{id}/handicap` will
        return `source: "calculated"` and the seed is no longer used.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [handicapIndex, source]
              properties:
                handicapIndex:
                  type: number
                  minimum: -10.0
                  maximum: 54.0
                  example: 14.2
                source:
                  type: string
                  enum: [dgv, official, manual, estimated]
                  example: "dgv"
                note:
                  type: string
                  nullable: true
                  example: "DGV Ausweis importiert"
      responses:
        "200":
          description: Seed saved.
          content:
            application/json:
              schema:
                type: object
                properties:
                  playerId:
                    type: string
                  handicapIndex:
                    type: number
                  source:
                    type: string
                  note:
                    type: string
                    nullable: true
                    example: "DGV Ausweis Stand Mai 2026"
                  message:
                    type: string
        "401":
          $ref: '#/components/responses/Unauthorized'
    delete:
      tags: [Identity]
      summary: Remove bootstrap Handicap Index
      description: |
        Permanently clears the player's bootstrap seed index.

        After this operation `hasHandicapSeed` returns `false` and `seed` returns `null`.
        If the player has fewer than 3 posted rounds, no handicap index will be available
        until sufficient rounds are posted.

        This operation is reversible — the player can re-seed at any time via `POST /me/handicap-seed`.
      responses:
        "200":
          description: Seed removed.
          content:
            application/json:
              schema:
                type: object
                properties:
                  playerId:
                    type: string
                  message:
                    type: string
        "401":
          $ref: '#/components/responses/Unauthorized'
        "404":
          $ref: '#/components/responses/PlayerNotFound'


  /players/{playerId}/handicap:
    get:
      tags: [Handicap]
      summary: Calculate Handicap Index with full audit trail
      description: |
        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      responses:
        "200":
          description: Handicap Index with WHS explanation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/HandicapResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/PlayerNotFound'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/simulate:
    post:
      tags: [Simulation]
      summary: Simulate handicap with hypothetical round(s)
      description: |
        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [hypotheticalRounds]
              properties:
                hypotheticalRounds:
                  type: array
                  minItems: 1
                  maxItems: 5
                  items:
                    type: object
                    required: [adjustedGrossScore, courseRating, slopeRating, par]
                    properties:
                      adjustedGrossScore:
                        type: integer
                      courseRating:
                        type: number
                      slopeRating:
                        type: integer
                      par:
                        type: integer
                      roundType:
                        type: string
                        enum: [18hole, 9front, 9back]
                        default: 18hole
                        description: |
                          Round type for WHS differential calculation.
                          Use `9front` or `9back` for nine-hole rounds.
      responses:
        "200":
          description: Simulated index.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/SimulationResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/simulate/target:
    post:
      tags: [Simulation]
      summary: What score is needed to reach a target index?
      description: |
        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TargetSimulationRequest'
      responses:
        "200":
          description: Required score to reach target.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TargetSimulationResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/impact:
    get:
      tags: [Analysis]
      summary: Per-round impact analysis
      description: |
        Shows each round's contribution to the current index.

        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      responses:
        "200":
          description: Per-round impact data.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ImpactAnalysisResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/trends:
    get:
      tags: [Analysis]
      summary: Trend and volatility analysis
      description: |
        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      responses:
        "200":
          description: Trend and consistency data.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TrendAnalysisResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/insights:
    get:
      tags: [Intelligence]
      summary: All insights (sorted critical-first)
      description: |
        Insights describe **what is happening** to your handicap.
        For **what to do about it**, use `/recommendations`.

        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      responses:
        "200":
          description: All insights.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InsightsResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/insights/critical:
    get:
      tags: [Intelligence]
      summary: Important and critical insights only
      description: |
        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      responses:
        "200":
          description: High-priority insights.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InsightsResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/recommendations:
    get:
      tags: [Intelligence]
      summary: Actionable recommendations (default feed)
      description: |
        Recommendations tell you **what to do**. Conflict-resolved and spam-protected.
        Monitoring-level items are excluded. Use `/recommendations/all` for those.

        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      responses:
        "200":
          description: Actionable recommendations.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecommendationsResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/recommendations/all:
    get:
      tags: [Intelligence]
      summary: All recommendations including monitoring signals
      description: |
        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      responses:
        "200":
          description: All recommendations.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RecommendationsResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/context:
    get:
      tags: [Intelligence]
      summary: Full player dashboard context
      description: |
        Single endpoint for a complete player overview.

        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.
      parameters:
        - $ref: '#/components/parameters/playerId'
      responses:
        "200":
          description: Player context summary.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PlayerContextResponse'
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/PlayerNotFound'
        "422":
          $ref: '#/components/responses/InsufficientHistory'

  /players/{playerId}/courses/stats:
    get:
      tags: [Analysis]
      summary: Per-course performance statistics
      description: |
        Aggregates all rounds posted by the player, grouped by course.

        Returns a sorted list of every course the player has played, with
        performance metrics (best/avg differential, deviation from HCP, trend).

        **Ownership-scoped** — `{playerId}` must match the `pid` claim of the JWT.

        ADR-032.
      parameters:
        - $ref: '#/components/parameters/playerId'
      responses:
        "200":
          description: Per-course statistics.
          content:
            application/json:
              schema:
                type: object
                properties:
                  playerId:
                    type: string
                  total:
                    type: integer
                    description: Number of distinct courses played.
                  courses:
                    type: array
                    items:
                      type: object
                      properties:
                        courseName:
                          type: string
                        roundsPlayed:
                          type: integer
                        bestDifferential:
                          type: number
                          format: float
                        avgDifferential:
                          type: number
                          format: float
                        avgVsIndex:
                          type: number
                          format: float
                          description: >
                            avgDifferential − currentIndex.
                            Negative = playing better than HCP on this course.
                        lastPlayed:
                          type: string
                          format: date
                        trend:
                          type: string
                          enum: [improving, stable, declining]
        "401":
          $ref: '#/components/responses/Unauthorized'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/PlayerNotFound'

  /v1/simulator/was-waere-wenn:
    post:
      tags: [PublicSimulator]
      summary: Was-wäre-wenn-Simulation (anonym)
      description: |
        Projiziert den neuen Handicap Index, wenn eine hypothetische Runde mit den angegebenen
        Parametern gespielt worden wäre.

        **Kein Login erforderlich** — der aktuelle Handicap Index wird vom Aufrufer übergeben.

        **Approximationsmodell** (`uniform_window_approximation`): Die Simulation modelliert das
        WHS-Berechnungsfenster als gleichmäßig verteilte Differenziale. Das Ergebnis ist eine
        mathematische Näherung — das tatsächliche Ergebnis hängt von der Rundenhistorie ab.
        Für exakte Berechnungen: [App mit Konto](/app/).

        **Formel (WHS §5.2):** `SD = (AGS − CR − PCC) × 113 / SR`
      security: []
      operationId: simulator.whatIf
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currentHandicapIndex, adjustedGrossScore, courseRating, slopeRating, par]
              properties:
                currentHandicapIndex:
                  type: number
                  format: float
                  minimum: -10.0
                  maximum: 54.0
                  description: Aktueller Handicap Index des Spielers.
                  example: 14.2
                adjustedGrossScore:
                  type: integer
                  minimum: 18
                  maximum: 270
                  description: Adjusted Gross Score (Brutto nach Net Double Bogey Kappung).
                  example: 88
                courseRating:
                  type: number
                  format: float
                  description: "Course Rating des gespielten Platzes (18H: 55–80, 9H: 25–77.5)."
                  example: 72.4
                slopeRating:
                  type: integer
                  minimum: 55
                  maximum: 155
                  description: Slope Rating des gespielten Platzes.
                  example: 131
                par:
                  type: integer
                  description: Par des gespielten Platzes.
                  example: 72
                playingConditionsCalculation:
                  type: integer
                  minimum: -1
                  maximum: 3
                  default: 0
                  description: |
                    Playing Conditions Calculation (PCC) — WHS §5.3.
                    Ganzzahliger Anpassungswert bei außergewöhnlichen Spielbedingungen.
                    Standard: 0 (gilt für die meisten Runden).
                roundType:
                  type: string
                  enum: [18hole, 9front, 9back]
                  default: 18hole
                  description: Rundentyp. 9-Loch-Differenziale werden vor der Projektion halbiert (WHS §3.1).
      responses:
        '200':
          description: Simulation erfolgreich berechnet
          content:
            application/json:
              schema:
                type: object
                required: [currentHandicapIndex, projectedHandicapIndex, scoreDifferential, delta, affectsHandicap, isSimulation]
                properties:
                  currentHandicapIndex:
                    type: number
                    format: float
                  projectedHandicapIndex:
                    type: number
                    format: float
                  scoreDifferential:
                    type: number
                    format: float
                  delta:
                    type: number
                    format: float
                    description: "currentHI − projectedHI. Positiv = Verbesserung."
                  affectsHandicap:
                    type: boolean
                    description: false wenn die Runde keinen Einfluss auf den Handicap Index hat.
                  wouldImprove:
                    type: boolean
                  roundType:
                    type: string
                  isSimulation:
                    type: boolean
                    enum: [true]
                  explainability:
                    type: object
                    properties:
                      model:
                        type: string
                        enum: [uniform_window_approximation]
                      modelNote:
                        type: string
                      steps:
                        type: array
                        items:
                          type: object
                          properties:
                            step: {type: string}
                            description: {type: string}
                            formula: {type: string}
                            value: {type: string}
        '400':
          $ref: '#/components/responses/ValidationError'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /v1/simulator/ziel-handicap:
    post:
      tags: [PublicSimulator]
      summary: Ziel-Handicap-Simulation (anonym)
      description: |
        Berechnet, welchen Score ein Spieler auf einem Platz benötigt, um einen
        Ziel-Handicap-Index zu erreichen.

        **Kein Login erforderlich.**

        Verwendet die inverse WHS-Formel (§5.2):
        `AGS = CR + requiredSD × SR / 113`

        `requiredSD = (8 × targetHI − 7 × currentHI) / 0.96`
      security: []
      operationId: simulator.targetHandicap
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currentHandicapIndex, targetHandicapIndex, courseRating, slopeRating, par]
              properties:
                currentHandicapIndex:
                  type: number
                  format: float
                  minimum: -10.0
                  maximum: 54.0
                  example: 14.2
                targetHandicapIndex:
                  type: number
                  format: float
                  minimum: -10.0
                  maximum: 54.0
                  description: Ziel-Handicap-Index der erreicht werden soll.
                  example: 12.0
                courseRating:
                  type: number
                  format: float
                  example: 72.4
                slopeRating:
                  type: integer
                  minimum: 55
                  maximum: 155
                  example: 131
                par:
                  type: integer
                  example: 72
                roundType:
                  type: string
                  enum: [18hole, 9front, 9back]
                  default: 18hole
      responses:
        '200':
          description: Ziel-Simulation berechnet
          content:
            application/json:
              schema:
                type: object
                required: [currentHandicapIndex, targetHandicapIndex, requiredDifferential, requiredAdjustedGrossScore, isAchievable, isSimulation]
                properties:
                  currentHandicapIndex: {type: number, format: float}
                  targetHandicapIndex: {type: number, format: float}
                  requiredDifferential:
                    type: number
                    format: float
                    description: Benötigtes Score Differential für das Ziel.
                  requiredAdjustedGrossScore:
                    type: integer
                    description: Benötigter AGS-Score auf dem angegebenen Platz.
                  courseRating: {type: number, format: float}
                  slopeRating: {type: integer}
                  par: {type: integer}
                  isAchievable:
                    type: boolean
                    description: false wenn der benötigte Score außerhalb des WHS-Bereichs (18–270) liegt.
                  roundType: {type: string}
                  narrativeSummary:
                    type: string
                    description: Menschenlesbare Erklärung des Ergebnisses.
                  isSimulation: {type: boolean, enum: [true]}
                  explainability:
                    type: object
                    properties:
                      model: {type: string}
                      modelNote: {type: string}
                      steps: {type: array, items: {type: object}}
        '400':
          $ref: '#/components/responses/ValidationError'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

  /v1/simulator/nicht-steigen:
    post:
      tags: [PublicSimulator]
      summary: Schutz vor HCP-Anstieg (anonym)
      description: |
        Berechnet den maximalen Score (schlechtestes Ergebnis), das ein Spieler auf einem
        Platz spielen kann, ohne den Handicap Index zu verschlechtern.

        **Kein Login erforderlich.**

        Jede Runde mit einem Score-Differential ≥ `thresholdDifferential` hat keinen Einfluss
        auf den Handicap Index (Runde nicht in den besten 8 von 20).

        `thresholdDifferential = currentHI / 0.96`

        `maxAGS = CR + threshold × SR / 113`
      security: []
      operationId: simulator.protectHandicap
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [currentHandicapIndex, courseRating, slopeRating, par]
              properties:
                currentHandicapIndex:
                  type: number
                  format: float
                  minimum: -10.0
                  maximum: 54.0
                  example: 14.2
                courseRating:
                  type: number
                  format: float
                  example: 72.4
                slopeRating:
                  type: integer
                  minimum: 55
                  maximum: 155
                  example: 131
                par:
                  type: integer
                  example: 72
                roundType:
                  type: string
                  enum: [18hole, 9front, 9back]
                  default: 18hole
      responses:
        '200':
          description: Schutz-Schwelle berechnet
          content:
            application/json:
              schema:
                type: object
                required: [currentHandicapIndex, thresholdDifferential, maximumAdjustedGrossScore, isAchievable, isSimulation]
                properties:
                  currentHandicapIndex: {type: number, format: float}
                  thresholdDifferential:
                    type: number
                    format: float
                    description: Score-Differential-Schwelle. Runden schlechter als dieser Wert haben keinen Einfluss auf den HI.
                  maximumAdjustedGrossScore:
                    type: integer
                    description: Maximaler AGS-Score der den HI nicht verschlechtert.
                  courseRating: {type: number, format: float}
                  slopeRating: {type: integer}
                  par: {type: integer}
                  isAchievable: {type: boolean}
                  roundType: {type: string}
                  narrativeSummary: {type: string}
                  isSimulation: {type: boolean, enum: [true]}
                  explainability:
                    type: object
                    properties:
                      model: {type: string}
                      modelNote: {type: string}
                      steps: {type: array, items: {type: object}}
        '400':
          $ref: '#/components/responses/ValidationError'
        '422':
          $ref: '#/components/responses/UnprocessableEntity'

tags:
  - name: System
    description: Operational health and monitoring endpoints. Public, no JWT required.
  - name: Courses
    description: Course catalogue — search, list, and tee data. Public endpoints.
  - name: Rounds
    description: Round submission (WHS Official + Quick Track) and scoring history.
  - name: Authentication
    description: Register and login — public endpoints, no JWT required.
  - name: MFA
    description: TOTP multi-factor authentication setup and management. Requires JWT.
  - name: Identity
    description: Current player state. Safe to call for new players.
  - name: Handicap
    description: WHS Handicap Index with full audit trail.
  - name: Simulation
    description: What-if scenarios without changing history.
  - name: Analysis
    description: Deep analysis of each round's impact and scoring trends.
  - name: Intelligence
    description: "Insights (what's happening) and Recommendations (what to do)."
  - name: PublicSimulator
    description: |
      Anonyme Simulations-Endpunkte — kein Login erforderlich.
      Was-wäre-wenn, Ziel-Handicap und Schutz vor HCP-Anstieg.
      Verwendet uniform-window-Approximation (WHS §5.2).
