Clients API
Auto-generated from the OpenAPI spec. Run
node docs-site/scripts/fetch-openapi.jsto regenerate.
GET /api/v1/clients/me
Get Own Profile
Responses
- 200 — Successful Response
PUT /api/v1/clients/me
Update Own Profile
Request body
| Field | Required | Type | Description |
|---|---|---|---|
full_name | No | string | |
phone | No | string | |
date_of_birth | No | string | |
notes | No | string | |
billing_name | No | string | |
billing_address | No | string | |
tax_id | No | string | |
is_active | No | boolean |
Responses
- 200 — Successful Response
- 422 — Validation Error
PUT /api/v1/clients/me/push-token
Update Push Token
Responses
- 200 — Successful Response
- 422 — Validation Error
GET /api/v1/clients
List Clients
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
search | query | No | ||
active_only | query | No | boolean | |
status | query | No | "active" | "inactive" | "all". Takes precedence over active_only when provided — "inactive" is evaluated fully server-side, so the response's total/pagination reflect the filtered count rather than the broad set. Omit to keep today's active_only-only behavior. | |
removed | query | No | boolean | Default false excludes removed clients. true shows only removed clients (the "Removed" tab), regardless of status/active_only. |
page | query | No | integer | |
page_size | query | No | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error
POST /api/v1/clients
Create Client
Create a client from the backoffice (no password yet). Sends invitation email.
Request body
| Field | Required | Type | Description |
|---|---|---|---|
full_name | Yes | string | |
email | Yes | string | |
phone | No | string |
Responses
- 201 — Successful Response
- 422 — Validation Error
GET /api/v1/clients/{client_id}
Get Client
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error
PUT /api/v1/clients/{client_id}
Update Client
Update a client's profile.
Also doubles as the genuine, reversible deactivate/reactivate path:
passing is_active: false/true flips the flag directly, leaving
full_name/email/phone/date_of_birth/notes untouched — same generic-PUT-
reactivation convention as locations/class_templates/membership_types/
products/promo_codes/waivers/staff-pay-components/gift-cards in this
codebase. This is deliberately distinct from anonymize_client (DELETE
/clients/{id} below), which is an irreversible GDPR action that wipes PII
and always sets is_active=False as a side effect — that endpoint is
unchanged by this addition and remains independently reachable.
Downstream effect: Client.is_active is already enforced at the
authentication boundary (app/auth.py::get_current_client and the login
handlers in app/routers/auth.py both reject an inactive client with
"not found or inactive" / AUTH_ACCOUNT_INACTIVE). So deactivating a
client here immediately blocks their next login and invalidates any
already-issued token's client-facing endpoints on next use; reactivating
restores both. No new authorization logic was added — this only feeds
into that existing check. Booking creation itself is not separately
gated on Client.is_active (a manager booking on a client's behalf via
POST /bookings does not check it), but that's an existing gap unrelated
to this endpoint, not something this task should build out.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer |
Request body
| Field | Required | Type | Description |
|---|---|---|---|
full_name | No | string | |
phone | No | string | |
date_of_birth | No | string | |
notes | No | string | |
billing_name | No | string | |
billing_address | No | string | |
tax_id | No | string | |
is_active | No | boolean |
Responses
- 200 — Successful Response
- 422 — Validation Error
DELETE /api/v1/clients/{client_id}
Anonymize Client
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error
POST /api/v1/clients/{client_id}/photo
Upload Client Photo
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error
DELETE /api/v1/clients/{client_id}/photo
Remove Client Photo
Clear a client's profile photo without uploading a replacement.
Idempotent: calling this when no photo is set is a no-op success, not a 404 — the end state (no photo) is what the caller asked for either way.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error
POST /api/v1/clients/{client_id}/deactivate
Deactivate Client
Reversible deactivate (manager only). Equivalent to
PUT /clients/\{id\} with \{"is_active": false\} but mirrors the
staff-management convention (app/routers/staff.py::deactivate_staff) of
a dedicated action verb. Distinct from anonymize_client below (DELETE
/clients/{id}), which is an irreversible GDPR action — this never
touches PII and is always reversible via reactivate_client.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error
POST /api/v1/clients/{client_id}/reactivate
Reactivate Client
Inverse of deactivate_client above (manager only).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error
POST /api/v1/clients/{client_id}/remove
Remove Client
Soft-remove a client (manager only). Mirrors
app/routers/staff.py::delete_staff's soft-remove/restore pattern
EXACTLY, but under its own POST .../remove route rather than the bare
DELETE verb, so it can never be confused with the pre-existing
DELETE /clients/\{id\} below (anonymize_client) — a completely
different, IRREVERSIBLE GDPR action that scrubs PII. Soft-remove here
never touches name/email/phone/DOB/notes.
Sets removed_at and force-sets is_active=False as part of removal
(removal is a superset of the separate deactivate/reactivate tier
above), which also blocks login (app/routers/auth.py's is_active
checks). Never deletes the row or blocks on, or removes, historical
records (bookings, memberships, payments, checkins) — those stay
resolvable forever.
Unlike delete_staff, there is no last-Manager or self-modification invariant to mirror here: this is a manager-only endpoint (require_manager) that only ever targets a Client, never the caller's own account — a Client token can never present as a manager, so there is no self-removal path to guard against (N/A, see task's security review).
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer |
Responses
- 200 — Successful Response
- 409
CLIENT_ALREADY_REMOVED— This client has already been removed. - 422 — Validation Error
POST /api/v1/clients/{client_id}/restore
Restore Client
Clears removed_at and reactivates the account — the symmetric
inverse of remove_client above forcing is_active=False as part of
removal. Mirrors app/routers/staff.py::restore_staff.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer |
Responses
- 200 — Successful Response
- 409
CLIENT_NOT_REMOVED— This client has not been removed. - 422 — Validation Error
GET /api/v1/clients/{client_id}/bookings
Get Client Bookings
List a client's bookings for the desktop client-detail screen.
Paginated (same page / page_size / \{items,total,page,page_size\}
convention as GET /clients). Each row carries the booking fields
(id, scheduled_class_id, status, created_at) plus two display fields
resolved via an outerjoin: class_name (the scheduled class's template
name) and class_starts_at (its start datetime). Both are null when the
scheduled class or its template row has since been deleted.
Ordering is newest class first: class_starts_at DESC with NULLs last
(bookings whose scheduled class is gone sort to the end), then id DESC
as a stable tiebreaker. total is the unpaginated booking count for the
client; an out-of-range page yields an empty items with the real
total.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer | |
page | query | No | integer | |
page_size | query | No | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error
GET /api/v1/clients/{client_id}/activity
Get Client Activity
List a client's combined activity (class bookings + appointments) for the desktop client-detail screen's unified "Bookings" tab.
Additive alongside GET /{client_id}/bookings (class-bookings-only,
unchanged, kept for any existing caller that wants that list alone).
Same auth as that endpoint — require_permission("bookings:view"), i.e.
manager or an instructor/custom role granted that permission; a client
token is rejected with 403 before any DB lookup, same as /bookings
(see that endpoint and test_client_cannot_view_client_bookings_by_id).
Each Booking and Appointment row for this client is normalised into
a ClientActivityItem (discriminated by type). Both result sets are
fetched for the client IN FULL — per-client booking/appointment counts
are small, unlike a studio-wide report — then merged and sorted in
Python by starts_at descending (NULLs last, matching /bookings'
ordering), and ONLY THEN sliced into the requested page. This is
deliberately not a concatenate-then-slice: computing each page from the
merged, sorted set is what keeps page boundaries correct when bookings
and appointments interleave by date.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer | |
page | query | No | integer | |
page_size | query | No | integer |
Response body (200) — {items, total, page, page_size}, same envelope
convention as /bookings and /memberships above. Each entry in items:
| Field | Type | Description |
|---|---|---|
id | integer | The underlying Booking's or Appointment's own id. Not unique across types — always pair with type when keying/deduping on the frontend. |
type | string | "class" or "appointment". |
starts_at | string (datetime), nullable | |
ends_at | string (datetime), nullable | |
title | string, nullable | |
status | string | Each type keeps its own status vocabulary as-is, not unified: class booking → confirmed|cancelled|no_show; appointment → confirmed|cancelled|completed|no_show. |
created_at | string (datetime) | |
instructor_name | string, nullable | Populated only for an appointment row; None for a class row or when the underlying appointment has no instructor. |
join_url | string, nullable | The Zoom join link, populated only for an online class row; None for an appointment row or an in-person class. Never the host/start link (docs/SECURITY_GUIDELINES.md §0). |
total is the combined (bookings + appointments) unpaginated count for the
client. Items are sorted over the full merged set by starts_at descending
(NULLs last), then id descending as a stable tiebreaker — not a naive
concatenate-then-slice of each type's own page.
Responses
- 200 — Successful Response
- 422 — Validation Error
GET /api/v1/clients/{client_id}/memberships
Get Client Memberships
List a client's memberships for the desktop client-detail screen.
Paginated (same page / page_size / \{items,total,page,page_size\}
convention as GET /clients). Each row carries the membership fields
(id, membership_type_id, status, starts_at, expires_at, credits_remaining)
plus three display fields: membership_type_name and credits_total (the
membership type's included credit count — null for unlimited / time-based
types, or a deleted type row) are resolved via an outerjoin; credits_used
is the membership's own stored booking-time usage ledger
(Membership.credits_used, default 0, never null). The desktop gauge uses
credits_used + credits_remaining as its denominator, so credits_total is
informational only.
Ordering is newest first: starts_at DESC, then id DESC as a stable
tiebreaker. total is the unpaginated membership count for the client; an
out-of-range page yields an empty items with the real total.
Parameters
| Name | In | Required | Type | Description |
|---|---|---|---|---|
client_id | path | Yes | integer | |
page | query | No | integer | |
page_size | query | No | integer |
Responses
- 200 — Successful Response
- 422 — Validation Error