{
  "openapi": "3.1.0",
  "info": {
    "title": "perFIT Mobile API",
    "version": "0.1.0-draft",
    "description": "The full list of API endpoints to implement for V1 of the perFIT mobile app\n(Hong Kong studio chain, two-sided Member + Coach fitness/PT booking app).\n\nEvery endpoint here is work to build. A prototype \"booking module\" exists in\nthe repo, but it persists to a local throwaway SQLite file (`server/perfit.db`)\nthat isn't even present in the snapshot - real V1 data will not live there.\nWhere a prototype shape exists it's reused here as a reference for the\ncontract, not as a signal the endpoint is finished.\n\n**Auth model (V1):** email + password and phone + OTP only. Bearer JWT\n(`Authorization: Bearer <access_token>`) on all `/me/*` and write paths.\nNo social/OAuth, no biometric in V1.\n\n**Money model:** the app is a *client* of the ledger, never the system of\nrecord. It initiates checkout, launches provider UI, and polls status -\nthe backend + provider webhook own final balance/entitlement truth.\n(See `scoping-docs/00_brief/PerFit_Payment_Backend_API_Boundary_Notes.md`.)\n\n**Do not carry these prototype bugs into the real implementation:**\n- No auth at all / full IDOR / open CORS / unauthenticated `credits/adjust`.\n- `DELETE /bookings/{id}` 24h refund window treats `{date}T{time}Z` as UTC;\n  times are actually HK (UTC+8) - off-by-8h window bug.\n- `POST /credits/adjust` does `Number(delta)` → `NaN` on non-numeric input.\n",
    "contact": {
      "name": "perFIT scoping (João)",
      "email": "Joao@futovia.com"
    }
  },
  "servers": [
    {
      "url": "https://api.perfit.example/v1",
      "description": "Production (backend team to provide real host)"
    },
    {
      "url": "http://localhost:3001",
      "description": "Local prototype booking server (throwaway SQLite, no auth)"
    }
  ],
  "tags": [
    {
      "name": "System",
      "description": "Health / infra."
    },
    {
      "name": "Auth",
      "description": "email + phone/OTP auth."
    },
    {
      "name": "Member Profile",
      "description": "Current member's profile, onboarding, settings, devices."
    },
    {
      "name": "Reference Data",
      "description": "Branches, coaches, classes, plans - mostly read-only catalog."
    },
    {
      "name": "Coaches",
      "description": "Coach directory browsing + coach-portal endpoints."
    },
    {
      "name": "Class Bookings",
      "description": "Group-class booking lifecycle."
    },
    {
      "name": "Private Training",
      "description": "1:1 PT lesson request → confirm/reject → refund."
    },
    {
      "name": "Catalog & Pricing",
      "description": "Credit packages, membership plans, price book."
    },
    {
      "name": "Wallet & Ledger",
      "description": "Balance + ledger-backed credit history."
    },
    {
      "name": "Checkout & Payments",
      "description": "backend-mediated checkout, status, refunds, receipts."
    },
    {
      "name": "Notifications",
      "description": "in-app feed + push-token registration."
    },
    {
      "name": "Content",
      "description": "News feed (may be CMS-owned)."
    },
    {
      "name": "Admin / CMS",
      "description": "Write endpoints that belong to staff/CMS, not the member app."
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/api/health": {
      "get": {
        "tags": [
          "System"
        ],
        "summary": "Liveness probe",
        "security": [],
        "responses": {
          "200": {
            "description": "OK",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "service": {
                      "type": "string",
                      "example": "perfit-booking"
                    },
                    "time": {
                      "type": "string",
                      "format": "date-time"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/auth/register": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Register a member (email + password)",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email",
                  "password",
                  "name"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "password": {
                    "type": "string",
                    "format": "password",
                    "minLength": 8
                  },
                  "name": {
                    "type": "string"
                  },
                  "phone": {
                    "type": "string",
                    "description": "E.164",
                    "e.g. +85298765432": null
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created; verification pending",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthTokens"
                }
              }
            }
          },
          "409": {
            "$ref": "#/components/responses/Error"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/auth/login": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Login (email or mobile + password)",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "password"
                ],
                "description": "Exactly one of `identifier` or `email` must be present. `identifier` accepts an email address or an E.164 mobile number; `email` is kept for older clients.",
                "properties": {
                  "identifier": {
                    "type": "string",
                    "description": "Email or E.164 mobile number"
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  },
                  "password": {
                    "type": "string",
                    "format": "password"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Authenticated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthTokens"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/auth/social/login": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Sign in with Google / Apple / Facebook (MP-26 seam)",
        "description": "Behind the server's SOCIAL_LOGIN_ENABLED flag (503 SOCIAL_LOGIN_DISABLED\nwhile off). The provider id token is verified server-side; sign-in is\nkeyed on the provider's stable subject. A provider-VERIFIED email that\nmatches an existing member links the identity to that account;\notherwise a fresh member account is created from the profile.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "provider",
                  "id_token"
                ],
                "properties": {
                  "provider": {
                    "type": "string",
                    "enum": [
                      "google",
                      "apple",
                      "facebook"
                    ]
                  },
                  "id_token": {
                    "type": "string",
                    "description": "provider-issued identity token"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Authenticated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthTokens"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "503": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/auth/otp/request": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Request a phone OTP",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "phone"
                ],
                "properties": {
                  "phone": {
                    "type": "string",
                    "description": "E.164"
                  },
                  "purpose": {
                    "type": "string",
                    "enum": [
                      "login",
                      "register",
                      "verify_phone"
                    ],
                    "default": "login"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "OTP dispatched (SMS). Rate-limited.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "otp_id": {
                      "type": "string"
                    },
                    "expires_in": {
                      "type": "integer",
                      "description": "seconds",
                      "example": 300
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/auth/otp/verify": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Verify OTP → tokens",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "otp_id",
                  "code"
                ],
                "properties": {
                  "otp_id": {
                    "type": "string"
                  },
                  "code": {
                    "type": "string",
                    "example": "482913"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Verified",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthTokens"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/auth/refresh": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Exchange refresh token for a new access token",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "refresh_token"
                ],
                "properties": {
                  "refresh_token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Refreshed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthTokens"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/auth/logout": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Revoke current session/refresh token",
        "responses": {
          "204": {
            "description": "Logged out"
          }
        }
      }
    },
    "/api/auth/forgot-password": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Start password reset (email link/code)",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "email"
                ],
                "properties": {
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Reset email sent if account exists (always 202)"
          }
        }
      }
    },
    "/api/auth/reset-password": {
      "post": {
        "tags": [
          "Auth"
        ],
        "summary": "Complete password reset",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token",
                  "new_password"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  },
                  "new_password": {
                    "type": "string",
                    "format": "password",
                    "minLength": 8
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Password changed"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me": {
      "get": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Current member's profile",
        "description": "Resolves the current user from the bearer token and returns their\nprofile. Replaces the app's mocked `PROFILE_USER`.\n",
        "responses": {
          "200": {
            "description": "Profile",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeProfile"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "patch": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Self-edit profile (email + body metrics only; identity fields locked)",
        "description": "In-app self-service edit. Accepts only email, location, and body metrics\n(see MeProfileUpdate); identity fields (name, phone, date_of_birth, gender,\nphoto) are staff-assisted and rejected server-side with a 400.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MeProfileUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeProfile"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/onboarding": {
      "put": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Persist onboarding data (resumable; closed once complete)",
        "description": "Persists the onboarding capture. Resumable until onboarding completes;\nafter that it is closed (409) so it cannot bypass the profile identity lock.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/OnboardingData"
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Saved"
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/consent": {
      "post": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Record onboarding consent (T&C, privacy, biometric)",
        "description": "Captures the versioned confirmation collected at onboarding. Biometric\nface-template enrolment itself runs on the branch hardware (not the app),\nbut the consent is recorded here for PDPO.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ConsentInput"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Recorded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberAgreements"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/photo": {
      "post": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Upload the profile photo (self; interim disk storage, MP-17)",
        "description": "A deliberate carve-out from the MeProfileUpdate identity lock: the\nphoto has its OWN endpoint. Body is base64 (max 2MB decoded); the\ndeclared content_type must match the image's magic bytes. The returned\npath is content-addressed and served at GET /uploads/{name} with\nimmutable caching; storage swaps to S3 when the IAM ask lands.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "data",
                  "content_type"
                ],
                "properties": {
                  "data": {
                    "type": "string",
                    "description": "base64"
                  },
                  "content_type": {
                    "type": "string",
                    "enum": [
                      "image/jpeg",
                      "image/png",
                      "image/webp"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Stored",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "profile_photo_url": {
                      "type": "string",
                      "example": "/uploads/u1-ab12cd34ef56.jpg"
                    }
                  },
                  "required": [
                    "profile_photo_url"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/qr": {
      "get": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Issue the member's QR badge token (60s, HMAC-signed)",
        "description": "The app-side stand-in for in-app face recognition (locked answer #10):\na short-lived signed token the app renders as a QR and refreshes\nbefore expiry. Verified server-side by the coach scan endpoint (or\nbranch hardware later); self-contained, nothing stored or revoked.\n",
        "responses": {
          "200": {
            "description": "Badge token",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/QrBadge"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/reviews/eligible": {
      "get": {
        "tags": [
          "Reviews"
        ],
        "summary": "Interactions the member can still review (MP-22)",
        "description": "Verified, concluded interactions (attended/past classes, past PT\nsessions, settled purchases) that do not yet carry a review from this\nmember for every subject they allow. Powers the rating prompt and the\n'complete it later' behavior - dismissing costs nothing, the item\nstays here until reviewed.\n",
        "responses": {
          "200": {
            "description": "Eligible sources, newest first",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/EligibleReviewSource"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/reviews": {
      "get": {
        "tags": [
          "Reviews"
        ],
        "summary": "The member's own reviews (all statuses, with reasons)",
        "responses": {
          "200": {
            "description": "Reviews, newest first",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ReviewItem"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Reviews"
        ],
        "summary": "Submit a review for a verified interaction (MP-22)",
        "description": "Acceptance rules from the signed doc: only after a verified\ninteraction the member actually had (403 NOT_ELIGIBLE otherwise); one\nreview per subject per interaction (409 ALREADY_REVIEWED); ratings of\nthree stars or below are accepted but NOT displayed publicly - they\nopen a customer-service follow-up instead; a low rating needs no\nwritten reason; tags are optional preset labels.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/ReviewInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReviewItem"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/reviews/{id}": {
      "patch": {
        "tags": [
          "Reviews"
        ],
        "summary": "Edit an own review (stars/comment/tags; visibility recomputed)",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "stars": {
                    "type": "integer",
                    "minimum": 1,
                    "maximum": 5
                  },
                  "comment": {
                    "type": "string",
                    "maxLength": 1000,
                    "nullable": true
                  },
                  "tags": {
                    "type": "array",
                    "items": {
                      "type": "string"
                    },
                    "maxItems": 5
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ReviewItem"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Reviews"
        ],
        "summary": "Delete an own review",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Deleted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coaches/{coachId}/reviews": {
      "get": {
        "tags": [
          "Reviews"
        ],
        "summary": "A coach's average rating + published written reviews",
        "description": "Average includes every non-moderated rating (suppression hides the\nTEXT, it does not game the number); the written list carries only\npublished (4-5 star, non-moderated) reviews, per the signed rule that\nthree-stars-or-below are not displayed publicly. Reviewer shown as\nfirst name + last initial; anonymous publishing is not offered.\n",
        "parameters": [
          {
            "name": "coachId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Summary",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CoachReviewsSummary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/support-requests": {
      "get": {
        "tags": [
          "Support"
        ],
        "summary": "The member's complaints, compliments and support requests",
        "responses": {
          "200": {
            "description": "Requests, newest first",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/SupportRequestItem"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Support"
        ],
        "summary": "Submit a complaint, compliment, support request or attendance report (MP-24)",
        "description": "Routed to a chosen branch or the general team; an optional image\nattaches via the same validated photo pipeline as profile photos; the\nresponse carries the reference number the member keeps. 'attendance'\ncovers the signed doc's report-missing-or-incorrect-attendance row.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "kind",
                  "subject",
                  "body"
                ],
                "properties": {
                  "kind": {
                    "type": "string",
                    "enum": [
                      "complaint",
                      "compliment",
                      "support",
                      "attendance"
                    ]
                  },
                  "subject": {
                    "type": "string",
                    "maxLength": 120
                  },
                  "body": {
                    "type": "string",
                    "maxLength": 4000
                  },
                  "branch_id": {
                    "type": "string",
                    "description": "routing target; omit for the general team"
                  },
                  "image": {
                    "type": "string",
                    "description": "base64; same limits as the photo upload"
                  },
                  "image_content_type": {
                    "type": "string",
                    "enum": [
                      "image/jpeg",
                      "image/png",
                      "image/webp"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Submitted, with the reference number",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SupportRequestItem"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/phone/verify": {
      "post": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Complete phone verification and attach the number (MP-32)",
        "description": "Redeems a verify_phone OTP for the AUTHENTICATED user and attaches\nthe proven number to their account. Deliberately separate from\n/api/auth/otp/verify: a phone-verification code must never mint a\nsession for whoever holds the phone. 409 PHONE_TAKEN when another\naccount owns the number; 409 PHONE_CHANGE_LOCKED when the account\nalready has a verified number (changes are staff-assisted per the\nsigned criteria).\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "otp_id",
                  "code"
                ],
                "properties": {
                  "otp_id": {
                    "type": "string"
                  },
                  "code": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Phone attached and verified",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "phone": {
                      "type": "string"
                    },
                    "phone_verified": {
                      "type": "boolean"
                    }
                  },
                  "required": [
                    "phone",
                    "phone_verified"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Code failure (OTP_INVALID / OTP_EXPIRED / OTP_MAX_ATTEMPTS). Deliberately NOT 401: a 401 here means the bearer token failed, and clients retry 401s after a token refresh - which would double-burn OTP attempts.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/settings": {
      "get": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Get app settings (notif prefs, language)",
        "responses": {
          "200": {
            "description": "Settings",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberSettings"
                }
              }
            }
          }
        }
      },
      "patch": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Update settings (partial merge)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/MemberSettingsUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberSettings"
                }
              }
            }
          }
        }
      }
    },
    "/api/me/devices": {
      "get": {
        "tags": [
          "Member Profile"
        ],
        "summary": "List logged-in devices/sessions",
        "description": "Backs the 'log out devices' screen. Ties to auth session records.",
        "responses": {
          "200": {
            "description": "Devices",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AccountDevice"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/me/devices/{deviceId}": {
      "delete": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Revoke a device session",
        "parameters": [
          {
            "name": "deviceId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Revoked"
          }
        }
      }
    },
    "/api/coach/login": {
      "post": {
        "tags": [
          "Coach"
        ],
        "summary": "Coach login (user code OR mobile + password)",
        "description": "Coach accounts are ERP-provisioned; there is no coach self-registration.\nProvide exactly one of user_code or mobile, plus the password.\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "password"
                ],
                "properties": {
                  "user_code": {
                    "type": "string"
                  },
                  "mobile": {
                    "type": "string",
                    "description": "E.164"
                  },
                  "password": {
                    "type": "string",
                    "format": "password"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Authenticated",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/AuthTokens"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "429": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/password/otp": {
      "post": {
        "tags": [
          "Coach"
        ],
        "summary": "Request a password-reset OTP (SMS)",
        "description": "Enter a user code, mobile, or email. An SMS OTP is sent to the coach's\nregistered mobile. Always returns 202 with an otp_id (no account\nenumeration).\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "user_code": {
                    "type": "string"
                  },
                  "mobile": {
                    "type": "string",
                    "description": "E.164"
                  },
                  "email": {
                    "type": "string",
                    "format": "email"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "OTP dispatched if the account exists",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "otp_id": {
                      "type": "string"
                    },
                    "expires_in": {
                      "type": "integer",
                      "description": "seconds"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/password/reset": {
      "post": {
        "tags": [
          "Coach"
        ],
        "summary": "Complete password reset via OTP",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "otp_id",
                  "code",
                  "new_password"
                ],
                "properties": {
                  "otp_id": {
                    "type": "string"
                  },
                  "code": {
                    "type": "string"
                  },
                  "new_password": {
                    "type": "string",
                    "format": "password",
                    "minLength": 8
                  }
                }
              }
            }
          }
        },
        "responses": {
          "204": {
            "description": "Password changed"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/logout": {
      "post": {
        "tags": [
          "Coach"
        ],
        "summary": "Revoke the current coach session",
        "responses": {
          "204": {
            "description": "Logged out"
          }
        }
      }
    },
    "/api/coach/profile": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "Read-only coach Profile Centre",
        "description": "All fields are read-only; edits are staff-assisted in the authorised backend.",
        "responses": {
          "200": {
            "description": "Coach profile",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CoachAccountProfile"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/sessions": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "List the coach's logged-in devices",
        "responses": {
          "200": {
            "description": "Devices",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AccountDevice"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/sessions/logout-others": {
      "post": {
        "tags": [
          "Coach"
        ],
        "summary": "Log out all other coach devices",
        "responses": {
          "204": {
            "description": "Other devices revoked"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/members/assigned": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "List the coach's assigned members",
        "description": "Only members assigned to this coach. Optional name / membership-number search.",
        "parameters": [
          {
            "name": "query",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Assigned members",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachAssignedMember"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/students": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "My students (Profile Centre view)",
        "description": "The coach's assigned members, minimal fields (name, gender, membership number).",
        "responses": {
          "200": {
            "description": "Students",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachAssignedMember"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/members/{memberId}/packages": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "PT packages available to this coach for this member",
        "description": "Only packages assigned to BOTH this coach and the member. A member not\nassigned to the coach (or unrelated packages) returns 404.\n",
        "parameters": [
          {
            "name": "memberId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Packages",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachMemberPackage"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/classes/{classId}/students/{memberId}": {
      "patch": {
        "tags": [
          "Coach"
        ],
        "summary": "Set one member's attendance on the coach's own class (check-in, MP-10)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "classId",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "in": "path",
            "name": "memberId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "booked",
                      "attended",
                      "absent",
                      "leave"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Updated attendance",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "member_id": {
                      "type": "string"
                    },
                    "status": {
                      "type": "string",
                      "enum": [
                        "booked",
                        "attended",
                        "absent",
                        "leave"
                      ]
                    }
                  },
                  "required": [
                    "member_id",
                    "status"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/classes/{classId}/scan-checkin": {
      "post": {
        "tags": [
          "Coach"
        ],
        "summary": "Scan a member's QR badge and mark them attended (MP-10)",
        "description": "Verifies the badge token (INVALID_QR / EXPIRED_QR, both 400) and marks\nthe member attended on THIS coach's class. Not enrolled -> 404 naming\nthe member so the coach can resolve it at the door.\n",
        "parameters": [
          {
            "name": "classId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checked in",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ScanCheckInResult"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/members/{memberId}/attendance": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "Attendance summary for an assigned member across this coach's classes",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "memberId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Attendance summary",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberAttendanceSummary"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/classes/{classId}": {
      "get": {
        "tags": [
          "Classes"
        ],
        "summary": "Member-facing class detail (availability, never remaining counts)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "classId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Class detail",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberClassDetail"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/notifications": {
      "get": {
        "tags": [
          "Notifications"
        ],
        "summary": "My notification feed (newest first) + unread count",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Feed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "items": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/MemberNotificationItem"
                      }
                    },
                    "unread": {
                      "type": "integer"
                    }
                  },
                  "required": [
                    "items",
                    "unread"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/notifications/read-all": {
      "post": {
        "tags": [
          "Notifications"
        ],
        "summary": "Mark every notification read",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Marked",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/push-tokens": {
      "post": {
        "tags": [
          "Notifications"
        ],
        "summary": "Register this device's Expo push token",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "token"
                ],
                "properties": {
                  "token": {
                    "type": "string"
                  },
                  "platform": {
                    "type": "string",
                    "enum": [
                      "ios",
                      "android"
                    ]
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Registered",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "enum": [
                        true
                      ]
                    }
                  },
                  "required": [
                    "ok"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/class-bookings": {
      "get": {
        "tags": [
          "Classes"
        ],
        "summary": "My class bookings + waitlist entries (history, newest first)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "responses": {
          "200": {
            "description": "Bookings",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MemberClassBooking"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Classes"
        ],
        "summary": "Book a class (active membership required; capacity-safe)",
        "description": "409 CLASS_FULL -> join the waitlist; 409 DUPLICATE_BOOKING /\nOVERLAPPING_BOOKING -> prevented; 403 MEMBERSHIP_INACTIVE;\n400 OUTSIDE_BOOKING_WINDOW (14-day window, configurable).\n",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "class_id"
                ],
                "properties": {
                  "class_id": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Booked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberClassBooking"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/class-bookings/{classId}": {
      "delete": {
        "tags": [
          "Classes"
        ],
        "summary": "Cancel a class booking (free until 24h before; late recorded after)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "classId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cancelled (late_cancellation indicates the <24h rule outcome)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberClassBooking"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/class-waitlist": {
      "post": {
        "tags": [
          "Classes"
        ],
        "summary": "Join a full class's waitlist (FIFO; auto-promoted; expires 2h before start)",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "class_id"
                ],
                "properties": {
                  "class_id": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Waitlisted with position",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MemberClassBooking"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "403": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/class-waitlist/{classId}": {
      "delete": {
        "tags": [
          "Classes"
        ],
        "summary": "Leave a waitlist",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "path",
            "name": "classId",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Removed",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "status": {
                      "type": "string",
                      "enum": [
                        "removed"
                      ]
                    }
                  },
                  "required": [
                    "status"
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/revenue": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "Coach revenue derived from delivered PT sessions (MP-6)",
        "description": "Revenue = delivered sessions valued at the package per-session rate\n(price_hkd / total_sessions). Sessions on packages without pricing are\nexcluded from all figures and surfaced via unpriced_sessions.\n\nWindow: either one of the preset periods, or a custom from/to range.\nfrom and to are HK calendar days (Asia/Hong_Kong, UTC+8); the window\nruns from the start of `from` to the END of `to`, so to is INCLUSIVE.\nSupplying both OVERRIDES period, and period is then ignored. Supplying\nonly one of them is a 400, as is a malformed or impossible date, a to\nearlier than from, or a range longer than 1830 days.\n",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "period",
            "schema": {
              "type": "string",
              "enum": [
                "1d",
                "1w",
                "1m",
                "3m",
                "ytd"
              ],
              "default": "1m"
            }
          },
          {
            "in": "query",
            "name": "from",
            "description": "HK calendar day the custom window opens; requires `to`",
            "schema": {
              "type": "string",
              "format": "date",
              "example": "2026-09-01"
            }
          },
          {
            "in": "query",
            "name": "to",
            "description": "HK calendar day the custom window closes, inclusive; requires `from`",
            "schema": {
              "type": "string",
              "format": "date",
              "example": "2026-09-15"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Revenue summary + chart series",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CoachRevenue"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/transactions": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "Coach transaction list (delivered PT sessions, newest first)",
        "description": "Accepts the same optional from/to HK-day window as /api/coach/revenue,\nwith identical validation, so the transaction table can be narrowed to\nthe window the revenue chart is showing. Omitted = all time.\n",
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "parameters": [
          {
            "in": "query",
            "name": "limit",
            "schema": {
              "type": "integer",
              "default": 50,
              "maximum": 200
            }
          },
          {
            "in": "query",
            "name": "from",
            "description": "HK calendar day the window opens; requires `to`",
            "schema": {
              "type": "string",
              "format": "date",
              "example": "2026-09-01"
            }
          },
          {
            "in": "query",
            "name": "to",
            "description": "HK calendar day the window closes, inclusive; requires `from`",
            "schema": {
              "type": "string",
              "format": "date",
              "example": "2026-09-15"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Transactions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachTransaction"
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/members/{memberId}/invoices": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "Invoice records for an assigned member (MP-7)",
        "description": "One invoice per booked PT session, written with the booking. rate_hkd\nis a snapshot of the package's per-session value at booking time - a\nlater package reprice never changes an issued invoice; null means the\npackage carried no pricing when the session was booked. Unassigned\nmember -> 404 (same non-enumerating rule as the packages endpoint).\n",
        "parameters": [
          {
            "name": "memberId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Invoice records, newest session first",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachMemberInvoice"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/pt-bookings": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "The coach's PT sessions (upcoming + past reservations)",
        "description": "All PT sessions this coach has booked, newest start first, with an is_past flag.",
        "responses": {
          "200": {
            "description": "PT sessions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PtSessionListItem"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "post": {
        "tags": [
          "Coach"
        ],
        "summary": "Book a PT session for an assigned member",
        "description": "Validates the coach-member assignment and the coach+member package\nownership, deducts one session from the package, and records the PT\nsession. Unrelated member/package -> 404; no sessions left ->\nNO_REMAINING_SESSIONS; expired package -> PACKAGE_EXPIRED.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PtBookingInput"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Booked",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PtBookingConfirmation"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/pt-bookings/{id}/checkin": {
      "post": {
        "tags": [
          "Coach Scheduling"
        ],
        "summary": "Check a member in to a PT session with their signature (MP-26)",
        "description": "The member signs on the coach's device; the signature arrives as raw\nstroke points and the SERVER renders the SVG (nothing user-supplied is\nstored verbatim, so the upload pipeline cannot carry active content).\nAllowed from 60 minutes before the session start; one check-in per\nsession.\n",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "signature"
                ],
                "properties": {
                  "signature": {
                    "type": "array",
                    "description": "strokes; each stroke is a list of [x, y] points",
                    "items": {
                      "type": "array",
                      "items": {
                        "type": "array",
                        "items": {
                          "type": "number"
                        }
                      }
                    }
                  },
                  "canvas_width": {
                    "type": "number",
                    "description": "logical canvas width the points were captured on (default 600)"
                  },
                  "canvas_height": {
                    "type": "number",
                    "description": "logical canvas height (default 300)"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Checked in; the updated session row",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PtSessionListItem"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/classes": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "The coach's class schedule in a time window",
        "description": "Classes this coach runs whose start falls in [start, end). Defaults to a rolling 28-day window (14 days before / 14 days after now) when start/end are omitted. Each item carries the booked head-count and capacity.\n",
        "parameters": [
          {
            "name": "start",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "end",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Coach classes in the window",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachClassSummary"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/classes/{classId}": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "Course details for one of the coach's classes",
        "parameters": [
          {
            "name": "classId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Class detail",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CoachClassDetail"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coach/classes/{classId}/students": {
      "get": {
        "tags": [
          "Coach"
        ],
        "summary": "Roster for one of the coach's classes",
        "description": "Enrolled members (name, gender, membership number) with their attendance status.",
        "parameters": [
          {
            "name": "classId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Class roster",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachClassRosterEntry"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/classes": {
      "get": {
        "tags": [
          "Member Profile"
        ],
        "summary": "Member-facing class schedule in a time window",
        "description": "Read-only listing of classes whose start falls in [start, end) (default rolling 28-day window). Carries booked count + capacity and derived is_full / is_past flags. Member self-booking is a later milestone.\n",
        "parameters": [
          {
            "name": "start",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          },
          {
            "name": "end",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "format": "date-time"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Classes in the window",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MemberClassSummary"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/branches": {
      "get": {
        "tags": [
          "Reference Data"
        ],
        "summary": "List branches with today's resolved hours + open_now (Project 3)",
        "description": "Public. Each item carries today's resolved opening hours (precedence: active closure > holiday override > weekly) and open_now, evaluated in Asia/Hong_Kong.\n",
        "security": [],
        "responses": {
          "200": {
            "description": "Active branches, ordered",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/BranchSummary"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/branches/{id}": {
      "get": {
        "tags": [
          "Reference Data"
        ],
        "summary": "Branch detail: weekly + today's hours, closures, announcements (Project 3)",
        "security": [],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Branch detail",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/BranchDetail"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/membership": {
      "get": {
        "tags": [
          "Member Profile"
        ],
        "summary": "The member's synced membership + entitlement (Project 3)",
        "description": "Available whenever authenticated - including frozen/suspended/expired (the access rule lets members still log in and view). `booking_blocked` reflects whether booking is currently permitted.\n",
        "responses": {
          "200": {
            "description": "Membership view",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/MeMembership"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/plans": {
      "get": {
        "tags": [
          "Reference Data",
          "Catalog & Pricing"
        ],
        "summary": "List membership plans",
        "security": [],
        "responses": {
          "200": {
            "description": "Plans (ordered by duration_months)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/MembershipPlan"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/classes": {
      "get": {
        "tags": [
          "Reference Data"
        ],
        "summary": "List classes (with coach/branch names)",
        "security": [],
        "parameters": [
          {
            "name": "branchId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "open",
                "full",
                "cancelled",
                "completed"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Classes ordered by date, start_time",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/ClassWithDetails"
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Admin / CMS"
        ],
        "summary": "Create a class (staff/CMS, not member app)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "title",
                  "coachId",
                  "branchId",
                  "classDate"
                ],
                "properties": {
                  "id": {
                    "type": "string",
                    "description": "default class-${ts}"
                  },
                  "title": {
                    "type": "string"
                  },
                  "coachId": {
                    "type": "string"
                  },
                  "branchId": {
                    "type": "string"
                  },
                  "classDate": {
                    "type": "string",
                    "format": "date"
                  },
                  "startTime": {
                    "type": "string",
                    "default": "00:00"
                  },
                  "endTime": {
                    "type": "string",
                    "default": "01:00"
                  },
                  "spotsTotal": {
                    "type": "integer",
                    "default": 10
                  },
                  "creditsCost": {
                    "type": "integer",
                    "default": 0
                  },
                  "description": {
                    "type": "string"
                  },
                  "location": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/classes/{classId}": {
      "get": {
        "tags": [
          "Reference Data"
        ],
        "summary": "Get one class (no joins)",
        "security": [],
        "parameters": [
          {
            "name": "classId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Class",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/GymClass"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coaches": {
      "get": {
        "tags": [
          "Coaches"
        ],
        "summary": "Member-facing coach directory (MP-23)",
        "description": "All active coaches with photo, specialties and rating; filterable by\nbranch and specialty. Availability calendars are Phase 2 per the\nsigned doc and are deliberately absent.\n",
        "parameters": [
          {
            "name": "branchId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "specialty",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Coaches, ordered by name",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachDirectoryEntry"
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coaches/{coachId}": {
      "get": {
        "tags": [
          "Coaches"
        ],
        "summary": "Coach directory detail: intro, certifications, assigned classes",
        "parameters": [
          {
            "name": "coachId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Coach detail",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CoachDirectoryDetail"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/coaches/{coachId}/availability": {
      "get": {
        "tags": [
          "Coaches"
        ],
        "summary": "Coach availability slots",
        "parameters": [
          {
            "name": "coachId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "date",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Availability",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachAvailability"
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Coaches",
          "Admin / CMS"
        ],
        "summary": "Upsert availability (coach/staff)",
        "parameters": [
          {
            "name": "coachId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "date",
                  "startTime",
                  "endTime",
                  "kind"
                ],
                "properties": {
                  "date": {
                    "type": "string",
                    "format": "date"
                  },
                  "startTime": {
                    "type": "string"
                  },
                  "endTime": {
                    "type": "string"
                  },
                  "kind": {
                    "type": "string",
                    "enum": [
                      "working",
                      "blockout",
                      "leave"
                    ]
                  },
                  "note": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Upserted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/coach/classes/{classId}/roster": {
      "get": {
        "tags": [
          "Coaches"
        ],
        "summary": "Roster of students for a coach's class",
        "description": "Backs the coach-portal class detail (CoachClassStudent list).",
        "parameters": [
          {
            "name": "classId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Students",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CoachClassStudent"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/members": {
      "post": {
        "tags": [
          "Admin / CMS"
        ],
        "summary": "Create member (staff/CMS; also creates wallet row)",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "id",
                  "memberCode",
                  "name"
                ],
                "properties": {
                  "id": {
                    "type": "string"
                  },
                  "memberCode": {
                    "type": "string"
                  },
                  "name": {
                    "type": "string"
                  },
                  "phone": {
                    "type": "string"
                  },
                  "email": {
                    "type": "string"
                  },
                  "dateOfBirth": {
                    "type": "string",
                    "format": "date"
                  },
                  "membershipExpiry": {
                    "type": "string",
                    "format": "date"
                  },
                  "perfitPoints": {
                    "type": "integer",
                    "default": 0
                  },
                  "initialCredits": {
                    "type": "integer",
                    "default": 0
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/members/{memberId}": {
      "get": {
        "tags": [
          "Admin / CMS"
        ],
        "summary": "Get member by id (staff; app uses GET /api/me)",
        "parameters": [
          {
            "name": "memberId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Member",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Member"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/members/{memberId}/wallet": {
      "get": {
        "tags": [
          "Wallet & Ledger"
        ],
        "summary": "Member wallet by id (staff; app uses GET /api/me/wallet)",
        "parameters": [
          {
            "name": "memberId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Wallet",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreditWallet"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/me/wallet": {
      "get": {
        "tags": [
          "Wallet & Ledger"
        ],
        "summary": "Current member's wallet (token-scoped)",
        "description": "Token-scoped wallet read for the current member.",
        "responses": {
          "200": {
            "description": "Wallet",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CreditWallet"
                }
              }
            }
          }
        }
      }
    },
    "/api/me/ledger": {
      "get": {
        "tags": [
          "Wallet & Ledger"
        ],
        "summary": "Ledger-backed credit history",
        "description": "Credits must be ledger-backed, not just a mutable balance. Each entry\ncarries source id, type, money amount, status, actor, reason.\n",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50
            }
          },
          {
            "name": "cursor",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Ledger page",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "entries": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/LedgerEntry"
                      }
                    },
                    "next_cursor": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/credit-packages": {
      "get": {
        "tags": [
          "Catalog & Pricing"
        ],
        "summary": "Purchasable credit top-up packages",
        "security": [],
        "responses": {
          "200": {
            "description": "Packages",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/CreditPackage"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/bookings": {
      "get": {
        "tags": [
          "Class Bookings"
        ],
        "summary": "List bookings (by memberId)",
        "description": "App uses `GET /api/me/bookings`; this id-param form is for staff/admin.",
        "parameters": [
          {
            "name": "memberId",
            "in": "query",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Bookings (booked_at DESC)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/BookingWithClass"
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Class Bookings"
        ],
        "summary": "Book a class (atomic; waitlist-aware)",
        "description": "Returns a **full envelope** with **201** (unlike other write endpoints).\nDeterministic id `bk-{memberId}-{classId}`; UNIQUE(member_id, class_id).\nRules: spots<=0 → waitlisted (no charge); else confirmed (deduct credits,\ndecrement spots, flip class to full at 0).\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "memberId",
                  "classId"
                ],
                "properties": {
                  "memberId": {
                    "type": "string"
                  },
                  "classId": {
                    "type": "string"
                  },
                  "actor": {
                    "type": "string",
                    "default": "api"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Booked or waitlisted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "ok": {
                      "type": "boolean",
                      "const": true
                    },
                    "data": {
                      "type": "object",
                      "properties": {
                        "bookingId": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string",
                          "enum": [
                            "confirmed",
                            "waitlisted"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "VALIDATION_ERROR",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "description": "Domain error: MEMBER_NOT_FOUND, MEMBER_INACTIVE, CLASS_NOT_FOUND, CLASS_CANCELLED, CLASS_COMPLETED, DUPLICATE_BOOKING, COACH_UNAVAILABLE, WALLET_NOT_FOUND, INSUFFICIENT_CREDITS, BOOKING_FAILED",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/me/bookings": {
      "get": {
        "tags": [
          "Class Bookings"
        ],
        "summary": "Current member's bookings (token-scoped)",
        "description": "Auth'd equivalent of `GET /api/bookings?memberId=...`.",
        "responses": {
          "200": {
            "description": "Bookings",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/BookingWithClass"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/bookings/{bookingId}": {
      "get": {
        "tags": [
          "Class Bookings"
        ],
        "summary": "Get a booking (no join)",
        "parameters": [
          {
            "name": "bookingId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Booking",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Booking"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      },
      "delete": {
        "tags": [
          "Class Bookings"
        ],
        "summary": "Cancel a booking (refund + waitlist promotion)",
        "description": "Refunds if `forceRefund===true` OR (outside 24h window AND credits_charged>0).\nFIFO waitlist promotion by position if promoted member's wallet suffices.\n\n⚠️ **BUG to fix:** 24h window builds `{class_date}T{start_time}:00Z` and\ntreats it as UTC, but times are HK (UTC+8) - the window is off by 8h.\n",
        "parameters": [
          {
            "name": "bookingId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "forceRefund": {
                    "type": "boolean",
                    "description": "only true is honored"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cancelled",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "refunded": {
                      "type": "integer"
                    },
                    "promoted": {
                      "type": "string",
                      "nullable": true
                    }
                  }
                }
              }
            }
          },
          "422": {
            "description": "BOOKING_NOT_FOUND, ALREADY_CANCELLED, COMPLETED, CLASS_NOT_FOUND, CANCEL_FAILED",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/bookings/{bookingId}/checkin": {
      "post": {
        "tags": [
          "Class Bookings"
        ],
        "summary": "Check in (creates checkin_event)",
        "description": "App requirement layers QR-camera check-in on top of this - the camera\nflow is app-side; this endpoint is the write. Errors 422:\nBOOKING_NOT_FOUND, ALREADY_CHECKED_IN, INVALID_STATUS.\n",
        "parameters": [
          {
            "name": "bookingId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Checked in",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "checkedIn": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/bookings/{bookingId}/checkout": {
      "post": {
        "tags": [
          "Class Bookings"
        ],
        "summary": "Check out (status → completed)",
        "parameters": [
          {
            "name": "bookingId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Checked out",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "checkedOut": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/bookings/{bookingId}/noshow": {
      "post": {
        "tags": [
          "Class Bookings"
        ],
        "summary": "Mark no-show (restores spot, no refund)",
        "parameters": [
          {
            "name": "bookingId",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "No-show recorded",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "noShow": {
                      "type": "boolean"
                    }
                  }
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/private-sessions": {
      "get": {
        "tags": [
          "Private Training"
        ],
        "summary": "List my private/PT sessions",
        "description": "Member sees own requests; coach sees requests directed to them (role from token).",
        "parameters": [
          {
            "name": "role",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "member",
                "coach"
              ]
            },
            "description": "Perspective; defaults to the token's role."
          },
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "requested",
                "confirmed",
                "rejected",
                "cancelled",
                "completed",
                "expired"
              ]
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Sessions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/PrivateSession"
                  }
                }
              }
            }
          }
        }
      },
      "post": {
        "tags": [
          "Private Training"
        ],
        "summary": "Request a 1:1 PT session with a coach",
        "description": "Member picks a coach + slot. Credits/points may be **held** (not spent)\nuntil the coach confirms. On reject/expire the hold is released/refunded.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "coach_id",
                  "branch_id",
                  "date",
                  "start_time",
                  "end_time"
                ],
                "properties": {
                  "coach_id": {
                    "type": "string"
                  },
                  "branch_id": {
                    "type": "string"
                  },
                  "date": {
                    "type": "string",
                    "format": "date"
                  },
                  "start_time": {
                    "type": "string"
                  },
                  "end_time": {
                    "type": "string"
                  },
                  "note": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Requested (pending coach confirmation)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PrivateSession"
                }
              }
            }
          },
          "422": {
            "description": "COACH_UNAVAILABLE, INSUFFICIENT_CREDITS, SLOT_TAKEN, MEMBER_INACTIVE",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/private-sessions/{id}": {
      "get": {
        "tags": [
          "Private Training"
        ],
        "summary": "Get a private session",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Session",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PrivateSession"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/private-sessions/{id}/confirm": {
      "post": {
        "tags": [
          "Private Training"
        ],
        "summary": "Coach confirms a PT request (captures hold)",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Confirmed",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PrivateSession"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/private-sessions/{id}/reject": {
      "post": {
        "tags": [
          "Private Training"
        ],
        "summary": "Coach rejects a PT request (releases hold / refunds points)",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Rejected; credits/points refunded",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PrivateSession"
                }
              }
            }
          }
        }
      }
    },
    "/api/private-sessions/{id}/cancel": {
      "post": {
        "tags": [
          "Private Training"
        ],
        "summary": "Member cancels a PT request/booking",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Cancelled (refund per policy/window)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PrivateSession"
                }
              }
            }
          }
        }
      }
    },
    "/api/checkout/sessions": {
      "post": {
        "tags": [
          "Checkout & Payments"
        ],
        "summary": "Create a checkout session (top-up or membership)",
        "description": "Backend creates a provider payment intent/session and returns a\nclient_secret/redirect_url. The app launches provider UI. **Success is\ndecided by the provider webhook → backend ledger, never the app.**\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "type"
                ],
                "properties": {
                  "type": {
                    "type": "string",
                    "enum": [
                      "credit_top_up",
                      "membership_purchase",
                      "membership_renewal"
                    ]
                  },
                  "package_id": {
                    "type": "string",
                    "description": "for credit_top_up"
                  },
                  "membership_plan_id": {
                    "type": "string",
                    "description": "for membership_*"
                  },
                  "redeem_points": {
                    "type": "integer",
                    "description": "optional points to apply"
                  },
                  "return_url": {
                    "type": "string",
                    "example": "perfit://checkout/return"
                  },
                  "cancel_url": {
                    "type": "string",
                    "example": "perfit://checkout/cancel"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Session created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CheckoutSession"
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "401": {
            "$ref": "#/components/responses/Error"
          },
          "404": {
            "$ref": "#/components/responses/Error"
          },
          "409": {
            "description": "code CHECKOUT_IN_PROGRESS - a recent checkout for the same product is still open (double-submit guard; also returned when a concurrent identical submit loses the unique-open-session insert race). Retry after the open session settles or expires (30 min TTL).\n",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "503": {
            "description": "code PAYMENTS_UNAVAILABLE - no payment provider configured",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/checkout/sessions/{id}": {
      "get": {
        "tags": [
          "Checkout & Payments"
        ],
        "summary": "Poll checkout status",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/CheckoutStatus"
                }
              }
            }
          }
        }
      }
    },
    "/api/payments/webhook": {
      "post": {
        "tags": [
          "Checkout & Payments"
        ],
        "summary": "Payment provider webhook (server-to-server; signed, not bearer)",
        "description": "The provider posts settlement events here. Authenticated by an X-Webhook-Signature header (HMAC over the raw body), NOT a bearer token. Success/failure is decided here and written to the ledger; idempotent on the provider event id. Body shape is provider-specific (opaque).\n",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "additionalProperties": true
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acknowledged (including idempotent replays)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "received": {
                      "type": "boolean",
                      "const": true
                    }
                  },
                  "required": [
                    "received"
                  ]
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          }
        }
      }
    },
    "/api/refund-requests": {
      "post": {
        "tags": [
          "Checkout & Payments"
        ],
        "summary": "Request a refund",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "reference_type",
                  "reference_id",
                  "reason"
                ],
                "properties": {
                  "reference_type": {
                    "type": "string",
                    "enum": [
                      "booking",
                      "top_up",
                      "membership"
                    ]
                  },
                  "reference_id": {
                    "type": "string"
                  },
                  "reason": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Refund request created (pending backend/finance)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RefundRequest"
                }
              }
            }
          }
        }
      }
    },
    "/api/refund-requests/{id}": {
      "get": {
        "tags": [
          "Checkout & Payments"
        ],
        "summary": "Refund request status",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/RefundRequest"
                }
              }
            }
          }
        }
      }
    },
    "/api/me/receipts": {
      "get": {
        "tags": [
          "Checkout & Payments"
        ],
        "summary": "List receipts",
        "responses": {
          "200": {
            "description": "Receipts",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Receipt"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/me/invoices": {
      "get": {
        "tags": [
          "Checkout & Payments"
        ],
        "summary": "List invoices",
        "responses": {
          "200": {
            "description": "Invoices",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/Invoice"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/invoices/{id}/pdf": {
      "get": {
        "tags": [
          "Checkout & Payments"
        ],
        "summary": "Download invoice PDF",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "PDF",
            "content": {
              "application/pdf": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              }
            }
          }
        }
      }
    },
    "/api/credits/adjust": {
      "post": {
        "tags": [
          "Admin / CMS"
        ],
        "summary": "Manual credit adjustment (staff/audited)",
        "description": "Not a member-app endpoint. ⚠️ **BUG:** `Number(delta)` yields NaN on\nnon-numeric input - validate. Must be authenticated + audited in prod.\n",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "memberId",
                  "delta"
                ],
                "properties": {
                  "memberId": {
                    "type": "string"
                  },
                  "delta": {
                    "type": "integer",
                    "description": "may be negative"
                  },
                  "actor": {
                    "type": "string"
                  },
                  "reason": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Adjusted",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "newBalance": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/Error"
          },
          "422": {
            "description": "WALLET_NOT_FOUND, NEGATIVE_BALANCE",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          }
        }
      }
    },
    "/api/audit": {
      "get": {
        "tags": [
          "Admin / CMS"
        ],
        "summary": "Audit log (staff)",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Audit entries (created_at DESC)",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/AuditLogEntry"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/news": {
      "get": {
        "tags": [
          "Content"
        ],
        "summary": "News/announcements feed (may be CMS-owned)",
        "security": [],
        "responses": {
          "200": {
            "description": "Articles",
            "content": {
              "application/json": {
                "schema": {
                  "type": "array",
                  "items": {
                    "$ref": "#/components/schemas/NewsArticle"
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/news/{id}": {
      "get": {
        "tags": [
          "Content"
        ],
        "summary": "News article detail",
        "security": [],
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Article",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/NewsArticle"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "JWT",
        "description": "Enforce on all authenticated routes; the prototype enforces nothing."
      }
    },
    "responses": {
      "Error": {
        "description": "Error envelope",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Write endpoints return `{ ok:false, error, code }`. HTTP-layer errors:\n404 NOT_FOUND / \"Not found\", 500 INTERNAL. Full domain code union below.\n",
        "properties": {
          "ok": {
            "type": "boolean",
            "const": false
          },
          "error": {
            "type": "string"
          },
          "code": {
            "type": "string",
            "enum": [
              "MEMBER_NOT_FOUND",
              "MEMBER_INACTIVE",
              "CLASS_NOT_FOUND",
              "CLASS_CANCELLED",
              "CLASS_COMPLETED",
              "DUPLICATE_BOOKING",
              "COACH_UNAVAILABLE",
              "WALLET_NOT_FOUND",
              "INSUFFICIENT_CREDITS",
              "BOOKING_NOT_FOUND",
              "ALREADY_CANCELLED",
              "COMPLETED",
              "INVALID_STATUS",
              "ALREADY_CHECKED_IN",
              "NEGATIVE_BALANCE",
              "BOOKING_FAILED",
              "CANCEL_FAILED",
              "VALIDATION_ERROR",
              "SLOT_TAKEN",
              "NOT_FOUND",
              "INTERNAL",
              "METHOD_NOT_ALLOWED",
              "UNSUPPORTED_MEDIA_TYPE",
              "PAYLOAD_TOO_LARGE",
              "EMAIL_TAKEN",
              "INVALID_CREDENTIALS",
              "UNAUTHENTICATED",
              "TOKEN_INVALID",
              "TOKEN_EXPIRED",
              "OTP_INVALID",
              "OTP_EXPIRED",
              "OTP_MAX_ATTEMPTS",
              "RATE_LIMITED",
              "RESET_TOKEN_INVALID",
              "ONBOARDING_COMPLETE",
              "NO_REMAINING_SESSIONS",
              "PACKAGE_EXPIRED",
              "PAYMENTS_UNAVAILABLE",
              "CHECKOUT_IN_PROGRESS",
              "MEMBERSHIP_INACTIVE",
              "OUTSIDE_BOOKING_WINDOW",
              "OVERLAPPING_BOOKING",
              "CLASS_FULL",
              "CLASS_NOT_FULL",
              "CLASS_NOT_BOOKABLE",
              "WAITLIST_CLOSED",
              "ALREADY_WAITLISTED",
              "INVALID_QR",
              "EXPIRED_QR",
              "NOT_ELIGIBLE",
              "ALREADY_REVIEWED",
              "SOCIAL_LOGIN_DISABLED",
              "PHONE_TAKEN",
              "PHONE_CHANGE_LOCKED"
            ]
          },
          "path": {
            "type": "string",
            "description": "present on 404 handler"
          },
          "method": {
            "type": "string",
            "description": "present on 404 handler"
          }
        },
        "required": [
          "ok",
          "error"
        ]
      },
      "AuthTokens": {
        "type": "object",
        "properties": {
          "access_token": {
            "type": "string",
            "description": "JWT"
          },
          "refresh_token": {
            "type": "string"
          },
          "token_type": {
            "type": "string",
            "const": "Bearer"
          },
          "expires_in": {
            "type": "integer",
            "description": "access token TTL seconds"
          },
          "member_id": {
            "type": "string",
            "example": "M00001"
          },
          "role": {
            "type": "string",
            "enum": [
              "member",
              "coach"
            ]
          }
        },
        "required": [
          "access_token",
          "refresh_token",
          "token_type",
          "role"
        ]
      },
      "DayHours": {
        "type": "object",
        "description": "Resolved hours for one day. closed/is_24h are terminal; otherwise open and close are 'HH:MM' and close <= open means the window spans midnight.\n",
        "properties": {
          "weekday": {
            "type": "integer",
            "minimum": 0,
            "maximum": 6,
            "description": "0=Sun..6=Sat; set on weekly rows"
          },
          "closed": {
            "type": "boolean"
          },
          "is_24h": {
            "type": "boolean"
          },
          "open": {
            "type": "string",
            "nullable": true,
            "example": "07:30"
          },
          "close": {
            "type": "string",
            "nullable": true,
            "example": "23:00"
          }
        },
        "required": [
          "closed",
          "is_24h",
          "open",
          "close"
        ]
      },
      "BranchAnnouncement": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "ends_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "is_global": {
            "type": "boolean",
            "description": "true when the announcement applies to every branch"
          }
        },
        "required": [
          "id",
          "title",
          "body",
          "is_global"
        ]
      },
      "BranchClosureInfo": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "reason": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "example": "weather"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time"
          },
          "ends_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "reason",
          "kind",
          "starts_at",
          "ends_at"
        ]
      },
      "BranchSummary": {
        "type": "object",
        "description": "Directory list row with today's resolved hours + open_now (Project 3).",
        "properties": {
          "id": {
            "type": "string",
            "example": "tsuen-wan"
          },
          "name": {
            "type": "string"
          },
          "name_zh": {
            "type": "string",
            "nullable": true
          },
          "address": {
            "type": "string"
          },
          "phone": {
            "type": "string",
            "nullable": true
          },
          "whatsapp": {
            "type": "string",
            "nullable": true
          },
          "open_now": {
            "type": "boolean"
          },
          "today_hours": {
            "$ref": "#/components/schemas/DayHours"
          },
          "has_active_closure": {
            "type": "boolean"
          }
        },
        "required": [
          "id",
          "name",
          "address",
          "open_now",
          "today_hours",
          "has_active_closure"
        ]
      },
      "BranchDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/BranchSummary"
          },
          {
            "type": "object",
            "properties": {
              "timezone": {
                "type": "string",
                "example": "Asia/Hong_Kong"
              },
              "today_is_public_holiday": {
                "type": "boolean"
              },
              "today_holiday_name": {
                "type": "string",
                "nullable": true
              },
              "weekly_hours": {
                "type": "array",
                "description": "Seven rows Sun..Sat, each with weekday set.",
                "items": {
                  "$ref": "#/components/schemas/DayHours"
                }
              },
              "closures": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/BranchClosureInfo"
                }
              },
              "announcements": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/BranchAnnouncement"
                }
              }
            },
            "required": [
              "timezone",
              "today_is_public_holiday",
              "weekly_hours",
              "closures",
              "announcements"
            ]
          }
        ]
      },
      "MeMembership": {
        "type": "object",
        "description": "The member's synced membership view. Cloudfit is the system of record; this is a read cache (Project 3).",
        "properties": {
          "member_id": {
            "type": "string",
            "description": "member_code, e.g. M00001"
          },
          "status": {
            "type": "string",
            "enum": [
              "member",
              "non_member",
              "frozen"
            ]
          },
          "entitlement_status": {
            "type": "string",
            "enum": [
              "active",
              "pending",
              "frozen",
              "suspended",
              "expired"
            ]
          },
          "start_date": {
            "type": "string",
            "format": "date",
            "nullable": true
          },
          "expiry_date": {
            "type": "string",
            "format": "date",
            "nullable": true
          },
          "days_until_expiry": {
            "type": "integer",
            "nullable": true
          },
          "expiring_soon": {
            "type": "boolean"
          },
          "booking_blocked": {
            "type": "boolean",
            "description": "true unless entitlement_status is active"
          },
          "branch_ids": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "multi-branch entitlement"
          },
          "synced_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        },
        "required": [
          "member_id",
          "status",
          "entitlement_status",
          "expiring_soon",
          "booking_blocked",
          "branch_ids"
        ]
      },
      "CoachAvailability": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "coach_id": {
            "type": "string"
          },
          "date": {
            "type": "string",
            "format": "date"
          },
          "start_time": {
            "type": "string"
          },
          "end_time": {
            "type": "string"
          },
          "kind": {
            "type": "string",
            "enum": [
              "working",
              "blockout",
              "leave"
            ]
          },
          "note": {
            "type": "string",
            "nullable": true
          }
        },
        "required": [
          "coach_id",
          "date",
          "start_time",
          "end_time",
          "kind"
        ]
      },
      "CoachClassStudent": {
        "type": "object",
        "description": "coach-portal class roster row.",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "attendance_percent": {
            "type": "number"
          },
          "branch": {
            "type": "string"
          },
          "member_id": {
            "type": "string"
          },
          "phone": {
            "type": "string"
          },
          "email": {
            "type": "string"
          },
          "date_of_birth": {
            "type": "string",
            "format": "date",
            "nullable": true
          },
          "join_date": {
            "type": "string",
            "format": "date"
          },
          "membership_expiry": {
            "type": "string",
            "format": "date",
            "nullable": true
          },
          "height_cm": {
            "type": "number",
            "nullable": true
          },
          "weight_kg": {
            "type": "number",
            "nullable": true
          }
        }
      },
      "MembershipPlan": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "1m"
          },
          "label": {
            "type": "string"
          },
          "duration_months": {
            "type": "integer"
          },
          "price_per_month": {
            "type": "number"
          },
          "discounted_per_month": {
            "type": "number",
            "nullable": true
          },
          "total_hkd": {
            "type": "number"
          }
        },
        "required": [
          "id",
          "label",
          "duration_months",
          "price_per_month",
          "total_hkd"
        ]
      },
      "CreditPackage": {
        "type": "object",
        "description": "purchasable top-up bundle. HKD_PER_CREDIT=10 in the app today.",
        "properties": {
          "id": {
            "type": "string",
            "example": "credits_900"
          },
          "label": {
            "type": "string",
            "example": "900 credits"
          },
          "credits": {
            "type": "integer",
            "example": 900
          },
          "price_hkd": {
            "type": "number",
            "example": 9000
          },
          "bonus_credits": {
            "type": "integer",
            "default": 0
          }
        },
        "required": [
          "id",
          "credits",
          "price_hkd"
        ]
      },
      "MemberClassDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/MemberClassSummary"
          },
          {
            "type": "object",
            "properties": {
              "description": {
                "type": "string",
                "nullable": true
              },
              "coach_name": {
                "type": "string",
                "nullable": true
              },
              "coach_id": {
                "type": "string",
                "nullable": true,
                "description": "for the coach rating/review lookup (MP-22)"
              },
              "cancellation_deadline": {
                "type": "string",
                "format": "date-time",
                "description": "free cancellation until this time (24h rule)"
              }
            }
          }
        ]
      },
      "MemberNotificationItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "booking_confirmed",
              "booking_cancelled",
              "waitlist_promoted",
              "payment_succeeded",
              "payment_failed",
              "membership_expiring",
              "review_update",
              "support_update"
            ]
          },
          "title": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "data": {
            "type": "object",
            "nullable": true,
            "additionalProperties": {
              "type": "string"
            }
          },
          "read": {
            "type": "boolean"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "type",
          "title",
          "body",
          "read",
          "created_at"
        ]
      },
      "MemberClassBooking": {
        "type": "object",
        "description": "one row of the member's booking/waitlist history.",
        "properties": {
          "class_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "venue": {
            "type": "string"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time"
          },
          "ends_at": {
            "type": "string",
            "format": "date-time"
          },
          "status": {
            "type": "string",
            "enum": [
              "booked",
              "upcoming",
              "completed",
              "cancelled",
              "absent",
              "waitlisted",
              "waitlist_expired"
            ]
          },
          "late_cancellation": {
            "type": "boolean",
            "description": "cancelled inside the 24h window"
          },
          "waitlist_position": {
            "type": "integer",
            "description": "FIFO position, 1-based"
          }
        },
        "required": [
          "class_id",
          "name",
          "venue",
          "starts_at",
          "ends_at",
          "status"
        ]
      },
      "MemberAttendanceSummary": {
        "type": "object",
        "description": "Past-class attendance for one member under ONE coach. The rate is over\nDECIDED outcomes only (attended vs absent); leave and unmarked classes\nare surfaced separately, never silently counted against the member.\n",
        "properties": {
          "member_id": {
            "type": "string"
          },
          "attended": {
            "type": "integer"
          },
          "absent": {
            "type": "integer"
          },
          "leave": {
            "type": "integer"
          },
          "unmarked": {
            "type": "integer"
          },
          "attendance_rate": {
            "type": "integer",
            "nullable": true,
            "description": "0-100; null until any decided outcome exists"
          },
          "recent": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "class_id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "starts_at": {
                  "type": "string",
                  "format": "date-time"
                },
                "status": {
                  "type": "string",
                  "enum": [
                    "booked",
                    "attended",
                    "absent",
                    "leave"
                  ]
                }
              },
              "required": [
                "class_id",
                "name",
                "starts_at",
                "status"
              ]
            }
          }
        },
        "required": [
          "member_id",
          "attended",
          "absent",
          "leave",
          "unmarked",
          "recent"
        ]
      },
      "CoachRevenue": {
        "type": "object",
        "properties": {
          "period": {
            "type": "string",
            "enum": [
              "1d",
              "1w",
              "1m",
              "3m",
              "ytd",
              "custom"
            ],
            "description": "the preset used, or \"custom\" when from/to were supplied"
          },
          "total_hkd": {
            "type": "number",
            "description": "earned within the period"
          },
          "all_time_hkd": {
            "type": "number"
          },
          "buckets": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "label": {
                  "type": "string",
                  "example": "Aug 12"
                },
                "amount_hkd": {
                  "type": "number"
                }
              },
              "required": [
                "label",
                "amount_hkd"
              ]
            }
          },
          "unpriced_sessions": {
            "type": "integer",
            "description": "delivered sessions INSIDE the window excluded because their package has no pricing (scoped like total_hkd)"
          },
          "unpriced_sessions_all_time": {
            "type": "integer",
            "description": "delivered sessions excluded across ALL time; all_time_hkd is understated by exactly these, whatever window is selected"
          },
          "from": {
            "type": "string",
            "format": "date",
            "description": "present only when period is \"custom\"; HK day the window opens"
          },
          "to": {
            "type": "string",
            "format": "date",
            "description": "present only when period is \"custom\"; HK day the window closes, inclusive"
          }
        },
        "required": [
          "period",
          "total_hkd",
          "all_time_hkd",
          "buckets",
          "unpriced_sessions",
          "unpriced_sessions_all_time"
        ]
      },
      "CoachTransaction": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "title": {
            "type": "string",
            "example": "PT session"
          },
          "subtitle": {
            "type": "string",
            "example": "Chan Tai Man · Studio 1"
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time"
          },
          "amount_hkd": {
            "type": "number",
            "nullable": true,
            "description": "null = unpriced package"
          }
        },
        "required": [
          "id",
          "title",
          "subtitle",
          "occurred_at"
        ]
      },
      "ClassStatus": {
        "type": "string",
        "description": "⚠️ CONFLICT: backend uses open|full|cancelled|completed. The app's\ndisplay models use available|waitlist|canceled AND available|waitlist|full\nwith British vs American cancel(l)ed. **Backend enum below is canonical;\nthe app must map.**\n",
        "enum": [
          "open",
          "full",
          "cancelled",
          "completed"
        ]
      },
      "GymClass": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "coach_id": {
            "type": "string"
          },
          "branch_id": {
            "type": "string"
          },
          "class_date": {
            "type": "string",
            "format": "date"
          },
          "start_time": {
            "type": "string",
            "example": "18:30"
          },
          "end_time": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/ClassStatus"
          },
          "spots_total": {
            "type": "integer"
          },
          "spots_available": {
            "type": "integer"
          },
          "waitlist_count": {
            "type": "integer"
          },
          "credits_cost": {
            "type": "integer"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "location": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "title",
          "coach_id",
          "branch_id",
          "class_date",
          "status"
        ]
      },
      "ClassWithDetails": {
        "allOf": [
          {
            "$ref": "#/components/schemas/GymClass"
          },
          {
            "type": "object",
            "properties": {
              "coach_name": {
                "type": "string"
              },
              "branch_name": {
                "type": "string"
              }
            },
            "required": [
              "coach_name",
              "branch_name"
            ]
          }
        ]
      },
      "Member": {
        "type": "object",
        "description": "snake_case DB row.",
        "properties": {
          "id": {
            "type": "string",
            "example": "M00001"
          },
          "member_code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "phone": {
            "type": "string",
            "nullable": true
          },
          "email": {
            "type": "string",
            "nullable": true
          },
          "date_of_birth": {
            "type": "string",
            "format": "date",
            "nullable": true
          },
          "membership_expiry": {
            "type": "string",
            "format": "date",
            "nullable": true
          },
          "perfit_points": {
            "type": "integer",
            "default": 0
          },
          "is_active": {
            "$ref": "#/components/schemas/IntBool"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "member_code",
          "name"
        ]
      },
      "MeProfile": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Member"
          },
          {
            "type": "object",
            "description": "Display extras the profile screen shows (PROFILE_USER).",
            "properties": {
              "first_name": {
                "type": "string"
              },
              "last_name": {
                "type": "string"
              },
              "location": {
                "type": "string"
              },
              "join_date": {
                "type": "string",
                "format": "date"
              },
              "promotion_code": {
                "type": "string"
              },
              "height_cm": {
                "type": "number",
                "nullable": true
              },
              "weight_kg": {
                "type": "number",
                "nullable": true
              },
              "age": {
                "type": "integer",
                "nullable": true
              },
              "bmi": {
                "type": "number",
                "nullable": true
              },
              "onboarding_completed": {
                "type": "boolean",
                "description": "consent + onboarding data both captured"
              },
              "profile_photo_url": {
                "type": "string",
                "nullable": true,
                "description": "self-uploaded photo (MP-17); /uploads path on the API host"
              }
            }
          }
        ]
      },
      "MeProfileUpdate": {
        "type": "object",
        "description": "In-app self-service profile edits (Project 2). Identity fields (name,\nphone, date_of_birth, gender, profile photo) are staff-assisted only and\nare NOT accepted here; the server rejects them.\n",
        "properties": {
          "email": {
            "type": "string",
            "format": "email"
          },
          "location": {
            "type": "string"
          },
          "height_cm": {
            "type": "number"
          },
          "weight_kg": {
            "type": "number"
          }
        }
      },
      "ConsentInput": {
        "type": "object",
        "description": "Onboarding consent. `accepted` is the single confirmation box (unchecked\nby default) covering T&C, privacy notice, and biometric consent. The\npolicy versions are SERVER-owned (not client input) and are returned in\nthe response so the client can display what was accepted.\n",
        "required": [
          "accepted"
        ],
        "properties": {
          "accepted": {
            "type": "boolean",
            "description": "must be true"
          }
        }
      },
      "MemberAgreements": {
        "type": "object",
        "properties": {
          "tnc_version": {
            "type": "string"
          },
          "privacy_version": {
            "type": "string"
          },
          "biometric_version": {
            "type": "string"
          },
          "accepted_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "tnc_version",
          "privacy_version",
          "biometric_version",
          "accepted_at"
        ]
      },
      "OnboardingData": {
        "type": "object",
        "description": "persist what OnboardingContext collects.",
        "properties": {
          "name": {
            "type": "string"
          },
          "nickname": {
            "type": "string"
          },
          "gender": {
            "type": "string",
            "enum": [
              "female",
              "male"
            ],
            "nullable": true
          },
          "birthday": {
            "type": "string",
            "format": "date"
          },
          "weight_kg": {
            "type": "number"
          },
          "height_cm": {
            "type": "number"
          },
          "goals": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "branches": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "frequency": {
            "type": "string",
            "nullable": true
          }
        }
      },
      "MemberSettings": {
        "type": "object",
        "description": "App settings (MP-25). notification_types are the per-message-type push\npreferences from the signed criteria; the in-app feed always records\nregardless (money/booking records are not suppressible), preferences\ngate the PUSH channel.\n",
        "properties": {
          "language": {
            "type": "string",
            "enum": [
              "en",
              "tc",
              "sc"
            ]
          },
          "push_enabled": {
            "type": "boolean",
            "description": "master push switch"
          },
          "notification_types": {
            "type": "object",
            "properties": {
              "bookings": {
                "type": "boolean"
              },
              "payments": {
                "type": "boolean"
              },
              "membership": {
                "type": "boolean"
              },
              "reviews": {
                "type": "boolean"
              },
              "support": {
                "type": "boolean"
              }
            },
            "required": [
              "bookings",
              "payments",
              "membership",
              "reviews",
              "support"
            ]
          }
        },
        "required": [
          "language",
          "push_enabled",
          "notification_types"
        ]
      },
      "MemberSettingsUpdate": {
        "type": "object",
        "description": "PATCH body for /api/me/settings: every field optional, sent fields\nmerge into the stored settings (notification_types merges per key,\nso flipping one toggle never resets the others).\n",
        "properties": {
          "language": {
            "type": "string",
            "enum": [
              "en",
              "tc",
              "sc"
            ]
          },
          "push_enabled": {
            "type": "boolean"
          },
          "notification_types": {
            "type": "object",
            "properties": {
              "bookings": {
                "type": "boolean"
              },
              "payments": {
                "type": "boolean"
              },
              "membership": {
                "type": "boolean"
              },
              "reviews": {
                "type": "boolean"
              },
              "support": {
                "type": "boolean"
              }
            }
          }
        }
      },
      "AccountDevice": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "is_current": {
            "type": "boolean"
          },
          "last_active": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "CoachDirectoryEntry": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "avatar_url": {
            "type": "string",
            "nullable": true
          },
          "branch_id": {
            "type": "string",
            "nullable": true
          },
          "specialties": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "average": {
            "type": "number",
            "nullable": true
          },
          "review_count": {
            "type": "integer"
          }
        },
        "required": [
          "id",
          "name",
          "avatar_url",
          "branch_id",
          "specialties",
          "average",
          "review_count"
        ]
      },
      "CoachDirectoryDetail": {
        "allOf": [
          {
            "$ref": "#/components/schemas/CoachDirectoryEntry"
          },
          {
            "type": "object",
            "properties": {
              "introduction": {
                "type": "string",
                "nullable": true
              },
              "certifications": {
                "type": "array",
                "items": {
                  "$ref": "#/components/schemas/CoachCertificate"
                }
              },
              "classes": {
                "type": "array",
                "description": "upcoming classes this coach runs (14-day window)",
                "items": {
                  "type": "object",
                  "properties": {
                    "id": {
                      "type": "string"
                    },
                    "name": {
                      "type": "string"
                    },
                    "venue": {
                      "type": "string"
                    },
                    "starts_at": {
                      "type": "string",
                      "format": "date-time"
                    },
                    "ends_at": {
                      "type": "string",
                      "format": "date-time"
                    }
                  },
                  "required": [
                    "id",
                    "name",
                    "venue",
                    "starts_at",
                    "ends_at"
                  ]
                }
              }
            },
            "required": [
              "introduction",
              "certifications",
              "classes"
            ]
          }
        ]
      },
      "CoachCertificate": {
        "type": "object",
        "description": "Read-only professional certificate; approval happens in the authorised backend.",
        "properties": {
          "name": {
            "type": "string"
          },
          "issuer": {
            "type": "string",
            "nullable": true
          },
          "issued_on": {
            "type": "string",
            "format": "date",
            "nullable": true
          }
        },
        "required": [
          "name"
        ]
      },
      "CoachAccountProfile": {
        "type": "object",
        "description": "The coach's own Profile Centre (distinct from the public coach directory card). snake_case; all fields read-only in the app.",
        "properties": {
          "id": {
            "type": "string",
            "description": "coach ID",
            "example": "C00001"
          },
          "user_code": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "phone": {
            "type": "string",
            "nullable": true
          },
          "email": {
            "type": "string",
            "nullable": true
          },
          "join_date": {
            "type": "string",
            "format": "date",
            "nullable": true
          },
          "profile_photo_url": {
            "type": "string",
            "nullable": true
          },
          "professional_certificates": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/CoachCertificate"
            }
          }
        },
        "required": [
          "id",
          "user_code",
          "name"
        ]
      },
      "CoachAssignedMember": {
        "type": "object",
        "description": "Minimal member view a coach may see (name, gender, membership number, photo).",
        "properties": {
          "id": {
            "type": "string",
            "description": "handle used as member_id when booking"
          },
          "name": {
            "type": "string"
          },
          "gender": {
            "type": "string",
            "enum": [
              "female",
              "male"
            ],
            "nullable": true
          },
          "membership_number": {
            "type": "string"
          },
          "avatar_url": {
            "type": "string",
            "nullable": true
          }
        },
        "required": [
          "id",
          "name",
          "membership_number"
        ]
      },
      "CoachMemberPackage": {
        "type": "object",
        "description": "A PT package available to a coach for a specific member.",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "remaining_sessions": {
            "type": "integer"
          },
          "expiry": {
            "type": "string",
            "format": "date",
            "nullable": true
          }
        },
        "required": [
          "id",
          "name",
          "remaining_sessions"
        ]
      },
      "CoachMemberInvoice": {
        "type": "object",
        "description": "Invoice record for one booked PT session (rate snapshotted at booking).",
        "properties": {
          "id": {
            "type": "string"
          },
          "pt_session_id": {
            "type": "string"
          },
          "package_name": {
            "type": "string"
          },
          "rate_hkd": {
            "type": "number",
            "nullable": true,
            "description": "per-session value at booking time; null = package was unpriced"
          },
          "session_starts_at": {
            "type": "string",
            "format": "date-time"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "pt_session_id",
          "package_name",
          "rate_hkd",
          "session_starts_at",
          "created_at"
        ]
      },
      "ReviewInput": {
        "type": "object",
        "required": [
          "subject_type",
          "source_type",
          "source_id",
          "stars"
        ],
        "properties": {
          "subject_type": {
            "type": "string",
            "enum": [
              "coach",
              "venue",
              "customer_service"
            ]
          },
          "source_type": {
            "type": "string",
            "enum": [
              "class",
              "pt_session",
              "purchase"
            ]
          },
          "source_id": {
            "type": "string"
          },
          "stars": {
            "type": "integer",
            "minimum": 1,
            "maximum": 5
          },
          "comment": {
            "type": "string",
            "maxLength": 1000
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string",
              "maxLength": 40
            },
            "maxItems": 5
          }
        }
      },
      "ReviewItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "subject_type": {
            "type": "string",
            "enum": [
              "coach",
              "venue",
              "customer_service"
            ]
          },
          "subject_id": {
            "type": "string",
            "nullable": true
          },
          "subject_label": {
            "type": "string",
            "description": "display name: coach name / venue / Customer service"
          },
          "source_type": {
            "type": "string",
            "enum": [
              "class",
              "pt_session",
              "purchase"
            ]
          },
          "source_id": {
            "type": "string"
          },
          "stars": {
            "type": "integer"
          },
          "comment": {
            "type": "string",
            "nullable": true
          },
          "tags": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "published",
              "suppressed",
              "hidden",
              "rejected"
            ]
          },
          "status_reason": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "subject_type",
          "subject_id",
          "subject_label",
          "source_type",
          "source_id",
          "stars",
          "comment",
          "tags",
          "status",
          "status_reason",
          "created_at"
        ]
      },
      "EligibleReviewSource": {
        "type": "object",
        "properties": {
          "source_type": {
            "type": "string",
            "enum": [
              "class",
              "pt_session",
              "purchase"
            ]
          },
          "source_id": {
            "type": "string"
          },
          "label": {
            "type": "string",
            "description": "what the member will recognise: class name / PT session / purchase description"
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time"
          },
          "subjects": {
            "type": "array",
            "description": "subjects still reviewable for this source",
            "items": {
              "type": "object",
              "properties": {
                "subject_type": {
                  "type": "string",
                  "enum": [
                    "coach",
                    "venue",
                    "customer_service"
                  ]
                },
                "subject_id": {
                  "type": "string",
                  "nullable": true
                },
                "subject_label": {
                  "type": "string"
                }
              },
              "required": [
                "subject_type",
                "subject_id",
                "subject_label"
              ]
            }
          }
        },
        "required": [
          "source_type",
          "source_id",
          "label",
          "occurred_at",
          "subjects"
        ]
      },
      "CoachReviewsSummary": {
        "type": "object",
        "properties": {
          "coach_id": {
            "type": "string"
          },
          "coach_name": {
            "type": "string"
          },
          "average": {
            "type": "number",
            "nullable": true
          },
          "count": {
            "type": "integer"
          },
          "reviews": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "stars": {
                  "type": "integer"
                },
                "comment": {
                  "type": "string",
                  "nullable": true
                },
                "tags": {
                  "type": "array",
                  "items": {
                    "type": "string"
                  },
                  "nullable": true
                },
                "reviewer": {
                  "type": "string",
                  "description": "first name + last initial"
                },
                "created_at": {
                  "type": "string",
                  "format": "date-time"
                }
              },
              "required": [
                "stars",
                "comment",
                "tags",
                "reviewer",
                "created_at"
              ]
            }
          }
        },
        "required": [
          "coach_id",
          "coach_name",
          "average",
          "count",
          "reviews"
        ]
      },
      "SupportRequestItem": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "ref_no": {
            "type": "string",
            "example": "SR-000042"
          },
          "kind": {
            "type": "string",
            "enum": [
              "complaint",
              "compliment",
              "support",
              "attendance"
            ]
          },
          "branch_id": {
            "type": "string",
            "nullable": true
          },
          "subject": {
            "type": "string"
          },
          "body": {
            "type": "string"
          },
          "image_url": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "open",
              "in_progress",
              "resolved"
            ]
          },
          "response": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "ref_no",
          "kind",
          "branch_id",
          "subject",
          "body",
          "image_url",
          "status",
          "response",
          "created_at"
        ]
      },
      "QrBadge": {
        "type": "object",
        "properties": {
          "token": {
            "type": "string",
            "description": "opaque signed token; render as a QR"
          },
          "expires_in_seconds": {
            "type": "integer",
            "example": 60
          }
        },
        "required": [
          "token",
          "expires_in_seconds"
        ]
      },
      "ScanCheckInResult": {
        "type": "object",
        "properties": {
          "member_id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "membership_number": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "booked",
              "attended",
              "absent",
              "leave"
            ]
          }
        },
        "required": [
          "member_id",
          "name",
          "membership_number",
          "status"
        ]
      },
      "PtBookingInput": {
        "type": "object",
        "required": [
          "member_id",
          "package_id",
          "venue",
          "starts_at"
        ],
        "properties": {
          "member_id": {
            "type": "string"
          },
          "package_id": {
            "type": "string"
          },
          "venue": {
            "type": "string",
            "maxLength": 120
          },
          "starts_at": {
            "type": "string",
            "format": "date-time",
            "description": "must be in the future"
          },
          "duration_minutes": {
            "type": "integer",
            "minimum": 15,
            "maximum": 240,
            "description": "session length in minutes; the server defaults omitted values to the standard 60"
          },
          "notes": {
            "type": "string",
            "maxLength": 1000
          }
        }
      },
      "PtBookingConfirmation": {
        "type": "object",
        "properties": {
          "pt_session_id": {
            "type": "string"
          },
          "member": {
            "$ref": "#/components/schemas/CoachAssignedMember"
          },
          "package": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "remaining_sessions": {
                "type": "integer"
              }
            },
            "required": [
              "id",
              "name",
              "remaining_sessions"
            ]
          },
          "venue": {
            "type": "string"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time"
          },
          "ends_at": {
            "type": "string",
            "format": "date-time"
          },
          "duration_minutes": {
            "type": "integer"
          },
          "notes": {
            "type": "string",
            "nullable": true
          }
        },
        "required": [
          "pt_session_id",
          "member",
          "package",
          "venue",
          "starts_at",
          "ends_at",
          "duration_minutes"
        ]
      },
      "PtSessionListItem": {
        "type": "object",
        "description": "A PT session in the coach's reservation history.",
        "properties": {
          "id": {
            "type": "string"
          },
          "member": {
            "$ref": "#/components/schemas/CoachAssignedMember"
          },
          "venue": {
            "type": "string"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time"
          },
          "ends_at": {
            "type": "string",
            "format": "date-time"
          },
          "duration_minutes": {
            "type": "integer"
          },
          "notes": {
            "type": "string",
            "nullable": true
          },
          "is_past": {
            "type": "boolean"
          },
          "checked_in_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "signature_url": {
            "type": "string",
            "nullable": true,
            "description": "member signature captured at check-in (MP-26)"
          }
        },
        "required": [
          "id",
          "member",
          "venue",
          "starts_at",
          "ends_at",
          "duration_minutes",
          "is_past",
          "checked_in_at",
          "signature_url"
        ]
      },
      "CoachClassSummary": {
        "type": "object",
        "description": "A class in the coach's schedule (list view).",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "venue": {
            "type": "string"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time"
          },
          "ends_at": {
            "type": "string",
            "format": "date-time"
          },
          "duration_minutes": {
            "type": "integer"
          },
          "booked_count": {
            "type": "integer"
          },
          "capacity": {
            "type": "integer"
          }
        },
        "required": [
          "id",
          "name",
          "venue",
          "starts_at",
          "ends_at",
          "duration_minutes",
          "booked_count",
          "capacity"
        ]
      },
      "CoachClassDetail": {
        "type": "object",
        "description": "Course details for one of the coach's classes.",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "venue": {
            "type": "string"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time"
          },
          "ends_at": {
            "type": "string",
            "format": "date-time"
          },
          "duration_minutes": {
            "type": "integer"
          },
          "booked_count": {
            "type": "integer"
          },
          "capacity": {
            "type": "integer"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "notes": {
            "type": "string",
            "nullable": true
          }
        },
        "required": [
          "id",
          "name",
          "venue",
          "starts_at",
          "ends_at",
          "duration_minutes",
          "booked_count",
          "capacity"
        ]
      },
      "CoachClassRosterEntry": {
        "type": "object",
        "description": "A member on a class roster (minimal view + attendance status).",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "gender": {
            "type": "string",
            "enum": [
              "female",
              "male"
            ],
            "nullable": true
          },
          "membership_number": {
            "type": "string"
          },
          "avatar_url": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "booked",
              "attended",
              "absent",
              "leave"
            ]
          }
        },
        "required": [
          "id",
          "name",
          "membership_number",
          "status"
        ]
      },
      "MemberClassSummary": {
        "type": "object",
        "description": "A class in the member-facing schedule (read-only).",
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "venue": {
            "type": "string"
          },
          "starts_at": {
            "type": "string",
            "format": "date-time"
          },
          "ends_at": {
            "type": "string",
            "format": "date-time"
          },
          "duration_minutes": {
            "type": "integer"
          },
          "booked_count": {
            "type": "integer"
          },
          "capacity": {
            "type": "integer"
          },
          "is_full": {
            "type": "boolean"
          },
          "is_past": {
            "type": "boolean"
          },
          "my_status": {
            "type": "string",
            "nullable": true,
            "enum": [
              "booked",
              "attended",
              "absent",
              "leave",
              "waitlisted",
              null
            ],
            "description": "the caller's own relationship to this class, if any"
          }
        },
        "required": [
          "id",
          "name",
          "venue",
          "starts_at",
          "ends_at",
          "duration_minutes",
          "booked_count",
          "capacity",
          "is_full",
          "is_past"
        ]
      },
      "CreditWallet": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "member_id": {
            "type": "string"
          },
          "available_credits": {
            "type": "integer",
            "minimum": 0
          },
          "on_hold_credits": {
            "type": "integer",
            "minimum": 0
          },
          "updated_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "member_id",
          "available_credits",
          "on_hold_credits"
        ]
      },
      "LedgerEntry": {
        "type": "object",
        "description": "ledger-backed, not a mutable balance field.",
        "properties": {
          "id": {
            "type": "string",
            "example": "txn_123"
          },
          "type": {
            "type": "string",
            "enum": [
              "top_up_paid",
              "class_booked",
              "booking_cancelled_refund",
              "no_show_penalty",
              "admin_adjustment",
              "payment_refunded",
              "private_session_hold",
              "private_session_captured",
              "private_session_refund"
            ]
          },
          "credits_delta": {
            "type": "integer",
            "description": "signed"
          },
          "money_amount": {
            "type": "number",
            "nullable": true
          },
          "currency": {
            "type": "string",
            "default": "HKD"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "settled",
              "reversed"
            ]
          },
          "source_id": {
            "type": "string",
            "nullable": true
          },
          "actor": {
            "type": "string",
            "nullable": true
          },
          "reason": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "type",
          "credits_delta",
          "status",
          "created_at"
        ]
      },
      "BookingStatus": {
        "type": "string",
        "enum": [
          "waitlisted",
          "confirmed",
          "checked_in",
          "completed",
          "cancelled",
          "no_show"
        ]
      },
      "Booking": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "bk-M00001-class-123"
          },
          "member_id": {
            "type": "string"
          },
          "class_id": {
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/BookingStatus"
          },
          "credits_charged": {
            "type": "integer",
            "default": 0
          },
          "booked_at": {
            "type": "string",
            "format": "date-time"
          },
          "cancelled_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "show_on_schedule": {
            "$ref": "#/components/schemas/IntBool"
          },
          "waitlist_position": {
            "type": "integer",
            "nullable": true
          }
        },
        "required": [
          "id",
          "member_id",
          "class_id",
          "status"
        ]
      },
      "BookingWithClass": {
        "allOf": [
          {
            "$ref": "#/components/schemas/Booking"
          },
          {
            "type": "object",
            "properties": {
              "class_title": {
                "type": "string"
              },
              "class_date": {
                "type": "string",
                "format": "date"
              },
              "start_time": {
                "type": "string"
              },
              "end_time": {
                "type": "string"
              }
            }
          }
        ]
      },
      "PrivateSession": {
        "type": "object",
        "description": "1:1 PT lesson lifecycle.",
        "properties": {
          "id": {
            "type": "string",
            "example": "pt-M00001-1699999999"
          },
          "member_id": {
            "type": "string"
          },
          "coach_id": {
            "type": "string"
          },
          "branch_id": {
            "type": "string"
          },
          "date": {
            "type": "string",
            "format": "date"
          },
          "start_time": {
            "type": "string"
          },
          "end_time": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "requested",
              "confirmed",
              "rejected",
              "cancelled",
              "completed",
              "expired"
            ]
          },
          "credits_held": {
            "type": "integer",
            "description": "held on request",
            "captured on confirm": null
          },
          "credits_charged": {
            "type": "integer",
            "nullable": true
          },
          "note": {
            "type": "string",
            "nullable": true
          },
          "reject_reason": {
            "type": "string",
            "nullable": true
          },
          "expires_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "member_id",
          "coach_id",
          "date",
          "start_time",
          "status"
        ]
      },
      "CheckoutSession": {
        "type": "object",
        "description": "provider-agnostic. No payment provider chosen yet (OPEN DECISION).",
        "properties": {
          "checkout_session_id": {
            "type": "string",
            "example": "chk_123"
          },
          "provider": {
            "type": "string",
            "example": "provider_name"
          },
          "client_secret": {
            "type": "string",
            "nullable": true
          },
          "redirect_url": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "succeeded",
              "failed",
              "cancelled",
              "expired"
            ]
          }
        },
        "required": [
          "checkout_session_id",
          "status"
        ]
      },
      "CheckoutStatus": {
        "type": "object",
        "properties": {
          "checkout_session_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "succeeded",
              "failed",
              "cancelled",
              "expired"
            ]
          },
          "wallet_updated": {
            "type": "boolean"
          },
          "receipt_id": {
            "type": "string",
            "nullable": true
          },
          "invoice_id": {
            "type": "string",
            "nullable": true
          }
        },
        "required": [
          "checkout_session_id",
          "status"
        ]
      },
      "RefundRequest": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "rr_123"
          },
          "reference_type": {
            "type": "string",
            "enum": [
              "booking",
              "top_up",
              "membership"
            ]
          },
          "reference_id": {
            "type": "string"
          },
          "reason": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "approved",
              "rejected",
              "processed"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        },
        "required": [
          "id",
          "reference_type",
          "reference_id",
          "status"
        ]
      },
      "Receipt": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "rcpt_123"
          },
          "amount_hkd": {
            "type": "number"
          },
          "description": {
            "type": "string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "Invoice": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "example": "inv_123"
          },
          "amount_hkd": {
            "type": "number"
          },
          "status": {
            "type": "string",
            "enum": [
              "issued",
              "paid",
              "void"
            ]
          },
          "pdf_url": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "NewsArticle": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "date": {
            "type": "string",
            "format": "date"
          },
          "image_url": {
            "type": "string",
            "nullable": true
          },
          "badge": {
            "type": "string",
            "enum": [
              "new",
              "duration"
            ],
            "nullable": true
          },
          "duration": {
            "type": "string",
            "nullable": true
          },
          "body": {
            "type": "array",
            "items": {
              "type": "string"
            }
          }
        },
        "required": [
          "id",
          "title"
        ]
      },
      "AuditLogEntry": {
        "type": "object",
        "properties": {
          "id": {
            "type": "integer"
          },
          "actor": {
            "type": "string"
          },
          "action": {
            "type": "string"
          },
          "entity_type": {
            "type": "string"
          },
          "entity_id": {
            "type": "string",
            "nullable": true
          },
          "details": {
            "type": "string",
            "nullable": true,
            "description": "JSON string"
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "IntBool": {
        "type": "integer",
        "enum": [
          0,
          1
        ],
        "description": "⚠️ CONFLICT: backend stores booleans as 0/1 (is_active, show_on_schedule,\nno_show); the app expects real booleans. Recommend the API emit real JSON\nbooleans and the backend map at the edge. Documented as 0/1 to match the\ncurrent DB truth.\n"
      }
    }
  }
}