From 75b52b1f30839e0cc7588c2967f39bce31a5ccdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 3 May 2026 21:42:59 +0200 Subject: [PATCH] chore(api): add OpenAPI 3.1 spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generates `docs/api/openapi.yaml` covering the full REST surface — 100+ paths across 24 tags, 130+ schemas under `components.schemas`. Two security schemes: `sessionCookie` (HttpOnly `healthlog_session`) plus `bearerAuth` (HTTP Bearer for the API tokens at `/api/tokens`). Admin routes are explicitly tagged + restricted to `sessionCookie`. `docs/api/README.md` documents how to preview the spec locally (`npx @redocly/cli preview docs/api/openapi.yaml`) and lists the few known lint warnings (localhost server, redirect endpoints without 2xx). Lint pass: 0 errors, 17 warnings (none introduced by this commit). Spec is hand-maintained for now; a future commit can wire it into a `pnpm openapi:lint` CI step. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/README.md | 76 + docs/api/openapi.yaml | 4960 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 5036 insertions(+) create mode 100644 docs/api/README.md create mode 100644 docs/api/openapi.yaml diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 000000000..0613cd95b --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,76 @@ +# HealthLog API Spec + +`openapi.yaml` is the canonical OpenAPI 3.1 description for the HealthLog +Next.js API. It is the source of truth for the native iOS client's +DTO codegen. + +## Preview locally + +Open an interactive Redoc preview in your browser: + +```bash +npx @redocly/cli preview docs/api/openapi.yaml +``` + +Generate static HTML for sharing: + +```bash +npx @redocly/cli build-docs docs/api/openapi.yaml --output docs/api/index.html +``` + +## Validate + +```bash +npx @redocly/cli lint docs/api/openapi.yaml +``` + +The spec is expected to be lint-clean (errors = 0). Some warnings are +intentional: + +- `no-server-example.com` — `http://localhost:3000` is the dev server. +- `operation-2xx-response` / `operation-4xx-response` — webhook + redirect + endpoints intentionally omit one or the other. +- `no-unused-components` — `ReminderPhase` is exposed for client codegen + even though no path response references it directly. + +## Layout + +- **`info`** — title, version (mirrored from `package.json`), description. +- **`servers`** — production `https://healthlog.bombeck.io` plus + `http://localhost:3000` for local development. +- **`tags`** — domain groupings (`Auth`, `Measurements`, `Medications`, + `Mood`, `Dashboard`, `Insights`, `Achievements`, `DoctorReport`, + `Integrations`, `User`, `Tokens`, `Notifications`, `Onboarding`, + `Health`, `Settings`, `Analytics`, `Export`, `AuditLog`, `Feedback`, + `BugReport`, `Monitoring`, `Webhooks`, `Ingest`, `Admin`). +- **`securitySchemes`**: + - `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`. + Required on `/api/ingest/medication`. + - `sessionCookie` — HttpOnly `healthlog_session` cookie set by the + password and passkey login flows. Used by the web app and any iOS + client running inside a web view. +- **`components.schemas`** — DTOs for all routes plus the underlying + Prisma models (e.g. `Measurement`, `Medication`, `MedicationIntakeEvent`, + `MoodEntry`, `User`, `DashboardLayout`, `ApiEnvelope`, `ApiError`). +- **`paths`** — all routes from `src/app/api/**/route.ts`, including + the `/api/admin/*` family which is tagged `Admin` and marked internal. + +## Conventions + +- Successful responses use the envelope `{ data, error: null }`. +- Error responses use the envelope `{ data: null, error: '' }`. +- Pagination uses `limit` + `offset` query parameters and a + `meta: { total, limit, offset }` block on the response. +- All timestamps are ISO 8601 / RFC 3339 (`format: date-time`) in UTC. +- IDs are CUIDs (opaque strings). + +## Updating the spec + +When adding or changing routes: + +1. Update the relevant route handler under `src/app/api/**/route.ts`. +2. Mirror the change in `openapi.yaml`. Keep `operationId` camelCase and + stable — the iOS codegen keys off it. +3. Run `npx @redocly/cli lint docs/api/openapi.yaml` and resolve all + errors. Warnings are tolerated when documented above. +4. Bump `info.version` if the change is breaking for clients. diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml new file mode 100644 index 000000000..b882b8b35 --- /dev/null +++ b/docs/api/openapi.yaml @@ -0,0 +1,4960 @@ +openapi: 3.1.0 + +info: + title: HealthLog API + version: 1.3.0 + description: | + REST API for HealthLog — a personal health-tracking PWA covering weight, + blood pressure, pulse, mood, medication compliance, blood glucose, and + sleep. The same API powers the Next.js web client and the native iOS + client. + + ## Authentication + + All non-public endpoints accept either a session cookie (`healthlog_session`, + obtained via password login or passkey verification) or a long-lived Bearer + token (`Authorization: Bearer hlk_...`) issued via `/api/tokens`. The two + schemes are interchangeable on user endpoints — clients pick whichever + suits them (cookies for the web app, Bearer for the iOS client and + machine-to-machine integrations). Admin endpoints (`/api/admin/*`) accept + only the session cookie; Bearer tokens never elevate to admin. + + The API uses an envelope response shape: `{ data, error }`. On success, + `data` carries the payload and `error` is `null`. On failure, `data` is + `null` and `error` is a human-readable message. + + ## Conventions + + - All timestamps are ISO 8601 (`date-time`) in UTC. + - All numeric IDs are CUIDs (opaque strings). + - Pagination uses `limit` + `offset` query parameters; responses include + a `meta: { total, limit, offset }` block. + - Rate limiting is applied per-IP or per-user on auth, ingest, export, + AI, and bug-report endpoints. Exceeding the budget yields a `429`. + + ## Admin endpoints + + Routes under `/api/admin/*` are tagged `Admin` and are intended for the + internal admin UI only. iOS clients should not consume them. + contact: + name: HealthLog + url: https://healthlog.bombeck.io + license: + name: MIT + url: https://github.com/MBombeck/healthlog/blob/main/LICENSE + +servers: + - url: https://healthlog.bombeck.io + description: Production + - url: http://localhost:3000 + description: Local development + +tags: + - name: Auth + description: Registration, login (password + passkey/WebAuthn), logout, session info, profile, password change. + - name: Measurements + description: CRUD for measurements (weight, BP, pulse, body fat, glucose, sleep, steps). + - name: Medications + description: Medication management, schedules, intake events, compliance, reminder phase config. + - name: Mood + description: Mood entries (manual + moodLog imports) and mood analytics. + - name: Dashboard + description: User-customizable dashboard widget layout. + - name: Insights + description: AI-generated insights, classifications (BMI, BP, mood, etc.), per-metric status, target ranges. + - name: Achievements + description: Gamification achievements (streaks, milestones). + - name: DoctorReport + description: Aggregated health data for client-side PDF export. + - name: Integrations + description: Withings, moodLog, Codex (ChatGPT) OAuth integrations. + - name: User + description: User-level configuration (AI provider, threshold overrides). + - name: Tokens + description: User-issued API tokens for external ingest. + - name: Notifications + description: Notification channels and preferences (Telegram, ntfy, Web Push). + - name: Onboarding + description: First-run profile setup. + - name: Health + description: Service health probe. + - name: Settings + description: Per-user settings (account deletion, data wipe, integrations, notifications). + - name: Analytics + description: Aggregated analytics summaries. + - name: Export + description: User data export (CSV/JSON) and re-import. + - name: AuditLog + description: User-facing audit log of own activity. + - name: Feedback + description: User feedback submission and history. + - name: BugReport + description: User bug reports (creates a GitHub issue when configured). + - name: Monitoring + description: Public monitoring/error reporting endpoints (CSP, Glitchtip, Umami proxy). + - name: Webhooks + description: Inbound webhooks (Withings, moodLog, Telegram). + - name: Ingest + description: External ingest endpoints authenticated via Bearer token. + - name: Admin + description: Internal admin-only endpoints. Not consumed by the iOS client. + +security: + - bearerAuth: [] + - sessionCookie: [] + +paths: + # ─── Auth ──────────────────────────────────────────────────────── + /api/auth/register: + post: + tags: [Auth] + summary: Register a new user (password) + operationId: registerUser + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/RegisterInput' } + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/RegisterResponseEnvelope' + '403': { $ref: '#/components/responses/Forbidden' } + '409': { $ref: '#/components/responses/Conflict' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + '500': { $ref: '#/components/responses/ServerError' } + + /api/auth/login: + post: + tags: [Auth] + summary: Login with email/username + password + operationId: loginUser + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/LoginInput' } + responses: + '200': + description: Authenticated + content: + application/json: + schema: { $ref: '#/components/schemas/LoginResponseEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + '500': { $ref: '#/components/responses/ServerError' } + + /api/auth/logout: + post: + tags: [Auth] + summary: Destroy current session + operationId: logoutUser + responses: + '200': + description: Logged out + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + loggedOut: { type: boolean } + '500': { $ref: '#/components/responses/ServerError' } + + /api/auth/me: + get: + tags: [Auth] + summary: Get current authenticated user + operationId: getCurrentUser + responses: + '200': + description: Current user + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/CurrentUser' } + '401': { $ref: '#/components/responses/Unauthorized' } + '500': { $ref: '#/components/responses/ServerError' } + + /api/auth/profile: + put: + tags: [Auth] + summary: Update profile (email, height, DOB, gender) + operationId: updateProfile + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ProfileInput' } + responses: + '200': + description: Updated profile + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/User' } + '401': { $ref: '#/components/responses/Unauthorized' } + '409': { $ref: '#/components/responses/Conflict' } + '422': { $ref: '#/components/responses/Unprocessable' } + '500': { $ref: '#/components/responses/ServerError' } + + /api/auth/password: + post: + tags: [Auth] + summary: Change password (invalidates other sessions) + operationId: changePassword + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ChangePasswordInput' } + responses: + '200': + description: Password changed + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + changed: { type: boolean } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + '500': { $ref: '#/components/responses/ServerError' } + + /api/auth/registration-status: + get: + tags: [Auth] + summary: Whether public registration is enabled + operationId: getRegistrationStatus + security: [] + responses: + '200': + description: Registration status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + registrationEnabled: { type: boolean } + + # ─── Passkeys (WebAuthn) ─────────────────────────────────────── + /api/auth/passkey/register-options: + post: + tags: [Auth] + summary: Begin passkey registration — get WebAuthn options + operationId: getPasskeyRegisterOptions + responses: + '200': + description: WebAuthn registration options + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/PasskeyRegisterOptions' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/auth/passkey/register-verify: + post: + tags: [Auth] + summary: Complete passkey registration — verify attestation + operationId: verifyPasskeyRegistration + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PasskeyVerifyRegistrationInput' } + responses: + '200': + description: Passkey registered + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + verified: { type: boolean } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/auth/passkey/login-options: + post: + tags: [Auth] + summary: Begin passkey login — get WebAuthn options + operationId: getPasskeyLoginOptions + security: [] + responses: + '200': + description: WebAuthn authentication options + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/PasskeyLoginOptions' } + '429': { $ref: '#/components/responses/RateLimited' } + + /api/auth/passkey/login-verify: + post: + tags: [Auth] + summary: Complete passkey login — verify assertion + operationId: verifyPasskeyLogin + security: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/PasskeyVerifyLoginInput' } + responses: + '200': + description: Authenticated via passkey + content: + application/json: + schema: { $ref: '#/components/schemas/LoginResponseEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + + /api/auth/passkeys: + get: + tags: [Auth] + summary: List the user's passkeys + operationId: listPasskeys + responses: + '200': + description: List of passkeys + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/PasskeySummary' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/auth/passkeys/{id}: + delete: + tags: [Auth] + summary: Delete a passkey + operationId: deletePasskey + parameters: + - $ref: '#/components/parameters/IdPathParam' + responses: + '200': + description: Deleted + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + success: { type: boolean } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + # ─── Codex (ChatGPT) OAuth ──────────────────────────────────── + /api/auth/codex/authorize: + get: + tags: [Integrations] + summary: Begin ChatGPT OAuth flow (redirects to ChatGPT) + operationId: codexAuthorize + responses: + '302': + description: Redirect to ChatGPT auth URL + '401': { $ref: '#/components/responses/Unauthorized' } + '429': { $ref: '#/components/responses/RateLimited' } + + /api/auth/codex/callback: + get: + tags: [Integrations] + summary: ChatGPT OAuth callback — exchange code for tokens + operationId: codexCallback + parameters: + - in: query + name: code + schema: { type: string } + - in: query + name: state + schema: { type: string } + - in: query + name: error + schema: { type: string } + responses: + '302': + description: Redirect back to /settings with status + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/auth/codex/disconnect: + delete: + tags: [Integrations] + summary: Disconnect ChatGPT integration + operationId: codexDisconnect + responses: + '200': + description: Disconnected + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + disconnected: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '429': { $ref: '#/components/responses/RateLimited' } + + # ─── Measurements ──────────────────────────────────────────────── + /api/measurements: + get: + tags: [Measurements] + summary: List measurements (paginated, filterable) + operationId: listMeasurements + parameters: + - in: query + name: type + schema: { $ref: '#/components/schemas/MeasurementType' } + - in: query + name: from + schema: { type: string, format: date-time } + - in: query + name: to + schema: { type: string, format: date-time } + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/OffsetParam' + - in: query + name: sortBy + schema: + type: string + enum: [type, value, measuredAt, source] + default: measuredAt + - in: query + name: sortDir + schema: + type: string + enum: [asc, desc] + default: desc + responses: + '200': + description: List of measurements + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + measurements: + type: array + items: { $ref: '#/components/schemas/Measurement' } + meta: { $ref: '#/components/schemas/PageMeta' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + '500': { $ref: '#/components/responses/ServerError' } + post: + tags: [Measurements] + summary: Create a measurement (single or batch) + operationId: createMeasurement + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/CreateMeasurementInput' + - type: array + items: { $ref: '#/components/schemas/CreateMeasurementInput' } + minItems: 1 + maxItems: 5 + responses: + '201': + description: Created + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + oneOf: + - $ref: '#/components/schemas/Measurement' + - type: array + items: { $ref: '#/components/schemas/Measurement' } + '401': { $ref: '#/components/responses/Unauthorized' } + '409': { $ref: '#/components/responses/Conflict' } + '422': { $ref: '#/components/responses/Unprocessable' } + '500': { $ref: '#/components/responses/ServerError' } + + /api/measurements/{id}: + parameters: + - $ref: '#/components/parameters/IdPathParam' + get: + tags: [Measurements] + summary: Get a single measurement + operationId: getMeasurement + responses: + '200': + description: Measurement + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/Measurement' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [Measurements] + summary: Update a measurement + operationId: updateMeasurement + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateMeasurementInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/Measurement' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + delete: + tags: [Measurements] + summary: Delete a measurement + operationId: deleteMeasurement + responses: + '200': + description: Deleted + content: + application/json: + schema: { $ref: '#/components/schemas/DeletedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + # ─── Medications ───────────────────────────────────────────────── + /api/medications: + get: + tags: [Medications] + summary: List medications (with today's intake summary) + operationId: listMedications + responses: + '200': + description: List + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/MedicationWithSchedules' } + '401': { $ref: '#/components/responses/Unauthorized' } + post: + tags: [Medications] + summary: Create a medication with schedules + operationId: createMedication + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateMedicationInput' } + responses: + '201': + description: Created + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationWithSchedules' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/medications/{id}: + parameters: + - $ref: '#/components/parameters/IdPathParam' + get: + tags: [Medications] + summary: Get a single medication + operationId: getMedication + responses: + '200': + description: Medication + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationWithSchedules' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [Medications] + summary: Update a medication + operationId: updateMedication + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateMedicationInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationWithSchedules' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + delete: + tags: [Medications] + summary: Delete a medication (revokes its API tokens) + operationId: deleteMedication + responses: + '200': + description: Deleted + content: + application/json: + schema: { $ref: '#/components/schemas/DeletedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /api/medications/{id}/intake: + parameters: + - $ref: '#/components/parameters/IdPathParam' + get: + tags: [Medications] + summary: List intake events for a medication + operationId: listMedicationIntakes + parameters: + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/OffsetParam' + - in: query + name: sortBy + schema: + type: string + enum: [scheduledFor, takenAt, source, createdAt] + default: scheduledFor + - in: query + name: sortDir + schema: { type: string, enum: [asc, desc], default: desc } + responses: + '200': + description: Events + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + events: + type: array + items: { $ref: '#/components/schemas/MedicationIntakeEvent' } + meta: { $ref: '#/components/schemas/PageMeta' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + post: + tags: [Medications] + summary: Log a medication intake or skip + operationId: createMedicationIntake + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateIntakeInput' } + responses: + '200': + description: Idempotent return of existing event + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationIntakeEvent' } + '201': + description: Created + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationIntakeEvent' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/medications/{id}/intake/{eventId}: + parameters: + - $ref: '#/components/parameters/IdPathParam' + - in: path + name: eventId + required: true + schema: { type: string } + put: + tags: [Medications] + summary: Update an intake event + operationId: updateMedicationIntake + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateIntakeInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationIntakeEvent' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + delete: + tags: [Medications] + summary: Delete an intake event + operationId: deleteMedicationIntake + responses: + '200': + description: Deleted + content: + application/json: + schema: { $ref: '#/components/schemas/DeletedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /api/medications/{id}/intake/import: + parameters: + - $ref: '#/components/parameters/IdPathParam' + post: + tags: [Medications] + summary: Bulk-import historical intakes for one medication + operationId: importMedicationIntakes + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: array + items: { $ref: '#/components/schemas/IntakeImportEntry' } + minItems: 1 + maxItems: 1000 + - type: object + description: Object whose first array-valued property is treated as the entries. + additionalProperties: true + responses: + '201': + description: Import result + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/IntakeImportResult' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/medications/{id}/intake/purge: + parameters: + - $ref: '#/components/parameters/IdPathParam' + delete: + tags: [Medications] + summary: Delete ALL intake events for a medication + operationId: purgeMedicationIntakes + responses: + '200': + description: Purged + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + purged: { type: boolean } + count: { type: integer } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /api/medications/{id}/compliance: + parameters: + - $ref: '#/components/parameters/IdPathParam' + get: + tags: [Medications] + summary: Compliance summary for a medication (7d, 30d, 90 daily) + operationId: getMedicationCompliance + responses: + '200': + description: Compliance + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationComplianceResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /api/medications/{id}/phase-config: + parameters: + - $ref: '#/components/parameters/IdPathParam' + get: + tags: [Medications] + summary: Get reminder-phase config (or defaults) + operationId: getPhaseConfig + responses: + '200': + description: Phase config + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/ReminderPhaseConfig' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [Medications] + summary: Update reminder-phase config + operationId: updatePhaseConfig + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ReminderPhaseConfig' } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/ReminderPhaseConfig' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [Medications] + summary: Reset reminder-phase config to defaults + operationId: resetPhaseConfig + responses: + '200': + description: Reset + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + reset: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /api/medications/{id}/api-endpoint: + parameters: + - $ref: '#/components/parameters/IdPathParam' + get: + tags: [Medications] + summary: Whether an external API token is enabled for this medication + operationId: getMedicationApiEndpoint + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + enabled: { type: boolean } + activeTokenCount: { type: integer } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [Medications] + summary: Enable / disable a medication-scoped API token + operationId: setMedicationApiEndpoint + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + enabled: { type: boolean } + required: [enabled] + responses: + '200': + description: Existing token reused or revocation result + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationApiEndpointResponse' } + '201': + description: New medication-scoped token created + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationApiEndpointResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/medications/intake-summary: + get: + tags: [Medications] + summary: Daily intake summary (for streak chart) + operationId: getIntakeSummary + parameters: + - in: query + name: from + schema: { type: string, format: date-time } + - in: query + name: to + schema: { type: string, format: date-time } + responses: + '200': + description: Summary + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/IntakeSummaryResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + # ─── Mood ───────────────────────────────────────────────────── + /api/mood-entries: + get: + tags: [Mood] + summary: List mood entries (paginated, filterable) + operationId: listMoodEntries + parameters: + - in: query + name: mood + schema: { $ref: '#/components/schemas/MoodLevel' } + - in: query + name: from + description: 'YYYY-MM-DD lower bound (inclusive).' + schema: { type: string } + - in: query + name: to + description: 'YYYY-MM-DD upper bound (inclusive).' + schema: { type: string } + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/OffsetParam' + - in: query + name: sortBy + schema: { type: string, default: moodLoggedAt } + - in: query + name: sortDir + schema: { type: string, enum: [asc, desc], default: desc } + responses: + '200': + description: Mood entries + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + entries: + type: array + items: { $ref: '#/components/schemas/MoodEntry' } + meta: { $ref: '#/components/schemas/PageMeta' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + post: + tags: [Mood] + summary: Create a mood entry + operationId: createMoodEntry + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateMoodEntryInput' } + responses: + '201': + description: Created + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MoodEntry' } + '401': { $ref: '#/components/responses/Unauthorized' } + '409': { $ref: '#/components/responses/Conflict' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/mood-entries/{id}: + parameters: + - $ref: '#/components/parameters/IdPathParam' + get: + tags: [Mood] + summary: Get a mood entry + operationId: getMoodEntry + responses: + '200': + description: Mood entry + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MoodEntry' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [Mood] + summary: Update a mood entry + operationId: updateMoodEntry + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateMoodEntryInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MoodEntry' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + delete: + tags: [Mood] + summary: Delete a mood entry + operationId: deleteMoodEntry + responses: + '200': + description: Deleted + content: + application/json: + schema: { $ref: '#/components/schemas/DeletedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /api/mood/analytics: + get: + tags: [Mood] + summary: Mood analytics (daily averages + summary) + operationId: getMoodAnalytics + responses: + '200': + description: Analytics + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MoodAnalytics' } + '401': { $ref: '#/components/responses/Unauthorized' } + + # ─── Dashboard ──────────────────────────────────────────────── + /api/dashboard/widgets: + get: + tags: [Dashboard] + summary: Get the resolved dashboard widget layout + operationId: getDashboardWidgets + responses: + '200': + description: Layout + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/DashboardLayout' } + '401': { $ref: '#/components/responses/Unauthorized' } + put: + tags: [Dashboard] + summary: Replace the dashboard widget layout + operationId: updateDashboardWidgets + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/DashboardLayout' } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/DashboardLayout' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + delete: + tags: [Dashboard] + summary: Reset dashboard layout to defaults + operationId: resetDashboardWidgets + responses: + '200': + description: Reset + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/DashboardLayout' } + '401': { $ref: '#/components/responses/Unauthorized' } + + # ─── Insights ──────────────────────────────────────────────── + /api/insights/comprehensive: + get: + tags: [Insights] + summary: Comprehensive 90-day analytics bundle + operationId: getComprehensiveInsights + responses: + '200': + description: Insights bundle + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/ComprehensiveInsights' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/insights/generate: + post: + tags: [Insights] + summary: Generate AI-narrative insights (cached for 24h) + operationId: generateInsights + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + force: { type: boolean, description: 'Bypass 24h cache' } + responses: + '200': + description: Insights + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightGenerateResult' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + '502': + description: Upstream AI provider failure + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + + /api/insights/settings: + get: + tags: [Insights] + summary: Get insights settings (privacy mode, provider status) + operationId: getInsightsSettings + responses: + '200': + description: Settings + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightsSettings' } + '401': { $ref: '#/components/responses/Unauthorized' } + put: + tags: [Insights] + summary: Update insights settings (privacy mode) + operationId: updateInsightsSettings + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + privacyMode: { type: string, enum: [aggregated, raw] } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + updated: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/insights/targets: + get: + tags: [Insights] + summary: Per-metric target ranges + classification + operationId: getInsightTargets + responses: + '200': + description: Targets + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightTargetsResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/insights/blood-pressure-status: + get: + tags: [Insights] + summary: BP-specific narrative status + operationId: getBloodPressureStatus + parameters: + - $ref: '#/components/parameters/LocaleParam' + responses: + '200': + description: BP status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightStatusBundle' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/insights/bmi-status: + get: + tags: [Insights] + summary: BMI narrative status + operationId: getBmiStatus + parameters: + - $ref: '#/components/parameters/LocaleParam' + responses: + '200': + description: BMI status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightStatusBundle' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/insights/general-status: + get: + tags: [Insights] + summary: General health narrative status + operationId: getGeneralStatus + parameters: + - $ref: '#/components/parameters/LocaleParam' + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightStatusBundle' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/insights/medication-compliance-status: + get: + tags: [Insights] + summary: Medication compliance narrative status + operationId: getMedicationComplianceStatus + parameters: + - $ref: '#/components/parameters/LocaleParam' + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightStatusBundle' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/insights/mood-status: + get: + tags: [Insights] + summary: Mood narrative status + operationId: getMoodStatus + parameters: + - $ref: '#/components/parameters/LocaleParam' + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightStatusBundle' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/insights/pulse-status: + get: + tags: [Insights] + summary: Pulse narrative status + operationId: getPulseStatus + parameters: + - $ref: '#/components/parameters/LocaleParam' + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightStatusBundle' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/insights/weight-status: + get: + tags: [Insights] + summary: Weight narrative status + operationId: getWeightStatus + parameters: + - $ref: '#/components/parameters/LocaleParam' + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/InsightStatusBundle' } + '401': { $ref: '#/components/responses/Unauthorized' } + + # ─── Achievements ───────────────────────────────────────────── + /api/gamification/achievements: + get: + tags: [Achievements] + summary: Get achievements progress + unlocks + operationId: getAchievements + responses: + '200': + description: Achievements + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/AchievementsResult' } + '401': { $ref: '#/components/responses/Unauthorized' } + + # ─── DoctorReport ───────────────────────────────────────────── + /api/doctor-report: + post: + tags: [DoctorReport] + summary: Aggregated health data for doctor-report PDF + operationId: getDoctorReport + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + days: + type: integer + minimum: 1 + maximum: 365 + default: 90 + responses: + '200': + description: Report data + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/DoctorReportResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '429': { $ref: '#/components/responses/RateLimited' } + + # ─── Onboarding ─────────────────────────────────────────────── + /api/onboarding/complete: + post: + tags: [Onboarding] + summary: Mark onboarding complete and save initial profile + operationId: completeOnboarding + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/OnboardingInput' } + responses: + '200': + description: Completed + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + completed: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + # ─── Health probe ───────────────────────────────────────────── + /api/health: + get: + tags: [Health] + summary: Service liveness probe + operationId: healthCheck + security: [] + responses: + '200': + description: Healthy + content: + application/json: + schema: { $ref: '#/components/schemas/HealthStatus' } + '503': + description: Degraded + content: + application/json: + schema: { $ref: '#/components/schemas/HealthStatus' } + + # ─── Tokens ────────────────────────────────────────────────── + /api/tokens: + get: + tags: [Tokens] + summary: List the user's API tokens (metadata only — secrets never returned) + operationId: listTokens + responses: + '200': + description: Tokens + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/ApiTokenSummary' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + post: + tags: [Tokens] + summary: Create an API token (token returned ONCE) + operationId: createToken + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateTokenInput' } + responses: + '201': + description: Created (token visible once) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + token: { type: string, example: 'hlk_' } + name: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/tokens/{id}: + parameters: + - $ref: '#/components/parameters/IdPathParam' + delete: + tags: [Tokens] + summary: Revoke an API token + operationId: revokeToken + responses: + '200': + description: Revoked + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + revoked: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + # ─── Ingest (external) ──────────────────────────────────────── + /api/ingest/medication: + post: + tags: [Ingest] + summary: External medication intake ingest (Bearer token, idempotent) + operationId: ingestMedicationIntake + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ExternalIntakeInput' } + responses: + '200': + description: Existing event returned (idempotent replay) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationIntakeEvent' } + '201': + description: Created + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MedicationIntakeEvent' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + + # ─── Notifications ──────────────────────────────────────────── + /api/notifications/preferences: + get: + tags: [Notifications] + summary: List notification channels + preferences + operationId: getNotificationPreferences + responses: + '200': + description: Preferences + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/NotificationPreferencesResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + put: + tags: [Notifications] + summary: Toggle a single channel/event preference + operationId: updateNotificationPreference + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateNotificationPreferenceInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/NotificationPreference' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/notifications/vapid: + get: + tags: [Notifications] + summary: Get VAPID public key (Web Push) + operationId: getVapidKey + security: [] + responses: + '200': + description: VAPID key + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + publicKey: { type: string } + '503': + description: Not configured + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + + /api/notifications/web-push: + post: + tags: [Notifications] + summary: Subscribe a Web Push endpoint + operationId: subscribeWebPush + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/WebPushSubscribeInput' } + responses: + '200': + description: Subscribed + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + subscribed: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + delete: + tags: [Notifications] + summary: Unsubscribe a Web Push endpoint + operationId: unsubscribeWebPush + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + endpoint: { type: string, format: uri } + required: [endpoint] + responses: + '200': + description: Unsubscribed + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + unsubscribed: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + # ─── User configuration ─────────────────────────────────────── + /api/user/ai-provider: + get: + tags: [User] + summary: Get user-level AI provider config + operationId: getUserAiProvider + responses: + '200': + description: Config + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/UserAiProvider' } + '401': { $ref: '#/components/responses/Unauthorized' } + patch: + tags: [User] + summary: Update user-level AI provider config + operationId: updateUserAiProvider + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/UpdateUserAiProviderInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + updated: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/user/thresholds: + get: + tags: [User] + summary: Get effective + override threshold ranges + operationId: getThresholds + responses: + '200': + description: Thresholds + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/ThresholdsResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + put: + tags: [User] + summary: Set per-metric threshold overrides (partial) + operationId: updateThresholds + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ThresholdOverrides' } + responses: + '200': + description: Updated + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + overrides: { $ref: '#/components/schemas/ThresholdOverrides' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + delete: + tags: [User] + summary: Reset threshold overrides (all or one metric) + operationId: resetThresholds + parameters: + - in: query + name: metric + schema: { type: string } + description: 'If supplied, only this metric is reset; otherwise all overrides are cleared.' + responses: + '200': + description: Reset + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + overrides: { $ref: '#/components/schemas/ThresholdOverrides' } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + + # ─── Analytics + AI test ────────────────────────────────────── + /api/analytics: + get: + tags: [Analytics] + summary: Per-type measurement summaries + BMI + BP-in-target + operationId: getAnalytics + responses: + '200': + description: Analytics + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/AnalyticsResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/ai/test: + post: + tags: [User] + summary: Test the configured AI provider + operationId: testAiProvider + responses: + '200': + description: AI test result + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/AiTestResult' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + '502': + description: Upstream AI provider failure + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + + # ─── Export / Import ────────────────────────────────────────── + /api/export: + get: + tags: [Export] + summary: Export user data (CSV or JSON) + operationId: exportData + parameters: + - in: query + name: format + schema: { type: string, enum: [csv, json], default: json } + - in: query + name: type + schema: + type: string + enum: [measurements, medications, intake, mood, all] + default: all + responses: + '200': + description: Export payload + content: + application/json: + schema: + type: object + properties: + data: + type: object + additionalProperties: true + text/csv: + schema: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + + /api/import: + post: + tags: [Export] + summary: Import previously exported data (JSON) + operationId: importData + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/ImportInput' } + responses: + '200': + description: Import stats + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/ImportResult' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + # ─── Audit log + Feedback + Bug report ──────────────────────── + /api/audit-log: + get: + tags: [AuditLog] + summary: Personal audit log entries + operationId: getAuditLog + parameters: + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/OffsetParam' + responses: + '200': + description: Entries + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/AuditLogResponse' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/feedback: + get: + tags: [Feedback] + summary: List the user's submitted feedback + operationId: listFeedback + parameters: + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/OffsetParam' + responses: + '200': + description: Feedback + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: { $ref: '#/components/schemas/FeedbackSummary' } + meta: { $ref: '#/components/schemas/PageMeta' } + '401': { $ref: '#/components/responses/Unauthorized' } + post: + tags: [Feedback] + summary: Submit feedback (with optional screenshot) + operationId: submitFeedback + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/CreateFeedbackInput' } + responses: + '201': + description: Created + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/FeedbackSummary' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + + /api/bugreport: + post: + tags: [BugReport] + summary: File a bug report (creates a GitHub issue when configured) + operationId: submitBugReport + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/BugReportInput' } + responses: + '200': + description: Issue created + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + issueNumber: { type: integer } + issueUrl: { type: string, format: uri } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + '429': { $ref: '#/components/responses/RateLimited' } + '500': { $ref: '#/components/responses/ServerError' } + '503': + description: Bug report not configured + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + + /api/bugreport/status: + get: + tags: [BugReport] + summary: Whether bug-report submission is configured + operationId: getBugReportStatus + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + configured: { type: boolean } + isAdmin: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '429': { $ref: '#/components/responses/RateLimited' } + + # ─── Settings (per-user) ────────────────────────────────────── + /api/settings/account: + delete: + tags: [Settings] + summary: Delete the user's account (and all data) + operationId: deleteAccount + responses: + '200': + description: Account deleted + content: + application/json: + schema: { $ref: '#/components/schemas/DeletedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/settings/data: + delete: + tags: [Settings] + summary: Wipe the user's data (keep account) + operationId: wipeData + responses: + '200': + description: Data wiped + content: + application/json: + schema: { $ref: '#/components/schemas/DeletedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/settings/global-services: + get: + tags: [Settings] + summary: Global service availability flags (telegram, ntfy, web-push, …) + operationId: getGlobalServices + responses: + '200': + description: Flags + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/GlobalServicesFlags' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/settings/moodlog: + put: + tags: [Settings] + summary: Configure moodLog integration + operationId: updateMoodLogSettings + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/MoodLogSettingsInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: { $ref: '#/components/schemas/UpdatedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + delete: + tags: [Settings] + summary: Disconnect moodLog integration + operationId: deleteMoodLogSettings + responses: + '200': + description: Removed + content: + application/json: + schema: { $ref: '#/components/schemas/DeletedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/settings/ntfy: + get: + tags: [Settings] + summary: Get ntfy channel settings + operationId: getNtfySettings + responses: + '200': + description: Config + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/NtfySettings' } + '401': { $ref: '#/components/responses/Unauthorized' } + put: + tags: [Settings] + summary: Update ntfy channel settings + operationId: updateNtfySettings + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/NtfySettingsInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: { $ref: '#/components/schemas/UpdatedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/settings/ntfy/test: + post: + tags: [Settings] + summary: Send a test ntfy notification + operationId: testNtfy + responses: + '200': + description: Test result + content: + application/json: + schema: { $ref: '#/components/schemas/ApiEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/settings/reminder-thresholds: + get: + tags: [Settings] + summary: Get reminder lateness thresholds (admin-defined defaults) + operationId: getReminderThresholds + responses: + '200': + description: Thresholds + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/ReminderThresholds' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/settings/telegram: + get: + tags: [Settings] + summary: Get Telegram channel settings + operationId: getTelegramSettings + responses: + '200': + description: Config + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/TelegramSettings' } + '401': { $ref: '#/components/responses/Unauthorized' } + put: + tags: [Settings] + summary: Update Telegram channel settings + operationId: updateTelegramSettings + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/TelegramSettingsInput' } + responses: + '200': + description: Updated + content: + application/json: + schema: { $ref: '#/components/schemas/UpdatedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/settings/telegram/test: + post: + tags: [Settings] + summary: Send a test Telegram message + operationId: testTelegram + responses: + '200': + description: Test result + content: + application/json: + schema: { $ref: '#/components/schemas/ApiEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + + # ─── Withings integration ──────────────────────────────────── + /api/withings/connect: + get: + tags: [Integrations] + summary: Begin Withings OAuth (302 redirect) + operationId: withingsConnect + responses: + '302': + description: Redirect to Withings + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/withings/callback: + get: + tags: [Integrations] + summary: Withings OAuth callback (302 to /settings) + operationId: withingsCallback + parameters: + - in: query + name: code + schema: { type: string } + - in: query + name: state + schema: { type: string } + responses: + '302': + description: Redirect back to /settings with status + + /api/withings/disconnect: + post: + tags: [Integrations] + summary: Disconnect Withings + operationId: withingsDisconnect + responses: + '200': + description: Disconnected + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + disconnected: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + + /api/withings/credentials: + get: + tags: [Integrations] + summary: Whether Withings client credentials are stored + operationId: getWithingsCredentialsStatus + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + hasCredentials: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + put: + tags: [Integrations] + summary: Save Withings client ID + secret (encrypted) + operationId: saveWithingsCredentials + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/WithingsCredentialsInput' } + responses: + '200': + description: Saved + content: + application/json: + schema: { $ref: '#/components/schemas/UpdatedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + delete: + tags: [Integrations] + summary: Delete Withings credentials (and disconnect) + operationId: deleteWithingsCredentials + responses: + '200': + description: Deleted + content: + application/json: + schema: { $ref: '#/components/schemas/DeletedEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/withings/status: + get: + tags: [Integrations] + summary: Withings connection status + operationId: withingsStatus + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/WithingsStatus' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/withings/sync: + post: + tags: [Integrations] + summary: Trigger a Withings sync (incremental or full) + operationId: withingsSync + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + fullSync: { type: boolean, default: false } + responses: + '200': + description: Sync result + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + imported: { type: integer } + fullSync: { type: boolean } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/withings/webhook: + post: + tags: [Webhooks] + summary: Withings webhook (notification of new measurements) + operationId: withingsWebhook + security: [] + parameters: + - in: query + name: secret + schema: { type: string } + description: 'Shared secret for webhook authentication.' + responses: + '200': + description: Acknowledged + content: + application/json: + schema: + type: object + properties: + status: { type: string } + '401': + description: Unauthorized + '429': + description: Rate limited + get: + tags: [Webhooks] + summary: Withings webhook URL verification + operationId: withingsWebhookVerifyGet + security: [] + parameters: + - in: query + name: secret + schema: { type: string } + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + status: { type: string } + '401': + description: Unauthorized + head: + tags: [Webhooks] + summary: Withings webhook URL verification (HEAD) + operationId: withingsWebhookVerifyHead + security: [] + parameters: + - in: query + name: secret + schema: { type: string } + responses: + '200': + description: OK + '401': + description: Unauthorized + + # ─── moodLog integration ───────────────────────────────────── + /api/integrations/moodlog/status: + get: + tags: [Integrations] + summary: moodLog connection status + operationId: moodLogStatus + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MoodLogStatus' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/integrations/moodlog/sync: + post: + tags: [Integrations] + summary: Trigger a moodLog sync + operationId: moodLogSync + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + fullSync: { type: boolean, default: false } + responses: + '200': + description: Sync result + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + imported: { type: integer } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/integrations/moodlog/webhook: + post: + tags: [Webhooks] + summary: Inbound webhook from moodLog + operationId: moodLogWebhook + security: [] + parameters: + - in: header + name: X-Webhook-Secret + required: true + schema: { type: string } + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '200': + description: Acknowledged + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '429': { $ref: '#/components/responses/RateLimited' } + + /api/telegram/webhook: + post: + tags: [Webhooks] + summary: Telegram inbound webhook + operationId: telegramWebhook + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '200': + description: Acknowledged + get: + tags: [Webhooks] + summary: Telegram webhook health probe + operationId: telegramWebhookGet + security: [] + responses: + '200': + description: OK + + # ─── Public monitoring / proxies ───────────────────────────── + /api/monitoring/csp-report: + post: + tags: [Monitoring] + summary: Browser CSP violation report sink + operationId: cspReport + security: [] + requestBody: + required: true + content: + application/csp-report: + schema: + type: object + additionalProperties: true + application/json: + schema: + type: object + additionalProperties: true + responses: + '204': + description: Accepted (no body) + + /api/monitoring/glitchtip: + post: + tags: [Monitoring] + summary: Forward errors to Glitchtip + operationId: glitchtipReport + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '204': + description: Accepted + + /api/monitoring/settings: + get: + tags: [Monitoring] + summary: Public monitoring settings (umami / glitchtip flags) + operationId: getMonitoringSettings + security: [] + responses: + '200': + description: Public settings + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/PublicMonitoringSettings' } + + /api/monitoring/umami-script: + get: + tags: [Monitoring] + summary: Server-side proxy for the Umami tracker script + operationId: getUmamiScript + security: [] + responses: + '200': + description: Tracker script + content: + application/javascript: + schema: { type: string } + + /api/send: + post: + tags: [Monitoring] + summary: Server-side proxy for Umami /api/send + operationId: umamiProxySend + security: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: true + responses: + '200': + description: Forwarded + '204': + description: No-op when umami is disabled + '413': + description: Payload too large + + # ─── Admin (internal) ──────────────────────────────────────── + /api/admin/ai-settings: + get: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: get admin-managed AI fallback settings' + operationId: adminGetAiSettings + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + put: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: update admin-managed AI fallback settings' + operationId: adminUpdateAiSettings + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/admin/audit-log: + get: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: cross-user audit log' + operationId: adminGetAuditLog + parameters: + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/OffsetParam' + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/data: + delete: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: wipe per-user or all data' + operationId: adminWipeData + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/feedback: + get: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: list all feedback' + operationId: adminListFeedback + parameters: + - $ref: '#/components/parameters/LimitParam' + - $ref: '#/components/parameters/OffsetParam' + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/feedback/{id}: + parameters: + - $ref: '#/components/parameters/IdPathParam' + patch: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: update feedback status / admin note' + operationId: adminUpdateFeedback + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + delete: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: delete feedback' + operationId: adminDeleteFeedback + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /api/admin/feedback/{id}/github: + parameters: + - $ref: '#/components/parameters/IdPathParam' + post: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: escalate feedback to a GitHub issue' + operationId: adminEscalateFeedback + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + /api/admin/monitoring/glitchtip-test: + post: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: send Glitchtip test event' + operationId: adminGlitchtipTest + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/monitoring/umami-test: + post: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: send Umami test ping' + operationId: adminUmamiTest + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/notifications/reminder-check: + post: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: trigger medication reminder evaluation' + operationId: adminTriggerReminderCheck + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/notifications/test: + post: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: send a test notification' + operationId: adminTestNotification + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/settings: + get: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: get app-wide settings' + operationId: adminGetSettings + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + put: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: update app-wide settings' + operationId: adminUpdateSettings + requestBody: + required: true + content: + application/json: + schema: { type: object, additionalProperties: true } + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/admin/status: + get: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: deep system status' + operationId: adminStatus + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/tokens: + get: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: list all API tokens (cross-user)' + operationId: adminListTokens + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/users: + get: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: list all users' + operationId: adminListUsers + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + + /api/admin/users/{id}: + parameters: + - $ref: '#/components/parameters/IdPathParam' + put: + tags: [Admin] + security: + - sessionCookie: [] + summary: 'Internal: update a user (username/email/role)' + operationId: adminUpdateUser + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/AdminUpdateUserInput' } + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '400': { $ref: '#/components/responses/BadRequest' } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/admin/users/{id}/reset-password: + parameters: + - $ref: '#/components/parameters/IdPathParam' + post: + tags: [Admin] + security: + - sessionCookie: [] + summary: "Internal: reset a user's password" + operationId: adminResetUserPassword + responses: + '200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } } + '401': { $ref: '#/components/responses/Unauthorized' } + '403': { $ref: '#/components/responses/Forbidden' } + '404': { $ref: '#/components/responses/NotFound' } + + # ─── iOS adapter endpoints (v1.3) ──────────────────────────────────── + + /api/dashboard/summary: + get: + tags: [Dashboard] + summary: 'iOS: aggregated dashboard summary' + description: | + Single-call aggregate for the iOS DashboardSummary view. Combines + greeting, current/longest activity-day streak, today's medication + compliance, optional highlight insight, and per-metric latest + + 7-day sparkline + trend. + operationId: getDashboardSummary + responses: + '200': + description: Dashboard summary + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/DashboardSummary' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/measurements/series: + get: + tags: [Measurements] + summary: 'iOS: time-series + summary stats for a metric kind' + operationId: getMeasurementSeries + parameters: + - in: query + name: kind + required: true + schema: { $ref: '#/components/schemas/MetricKind' } + - in: query + name: days + schema: + type: integer + minimum: 1 + maximum: 365 + default: 30 + responses: + '200': + description: Series payload + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/MeasurementSeries' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/integrations/healthkit: + get: + tags: [Integrations] + summary: 'iOS: HealthKit per-metric direction config' + operationId: getHealthKitConfig + responses: + '200': + description: HealthKit config + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/HealthKitConfig' } + '401': { $ref: '#/components/responses/Unauthorized' } + patch: + tags: [Integrations] + summary: 'iOS: update HealthKit per-metric direction config' + operationId: updateHealthKitConfig + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/HealthKitConfigPatch' } + responses: + '200': + description: Updated config + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/HealthKitConfig' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/user/profile: + get: + tags: [Auth] + summary: 'iOS: flattened user profile' + operationId: getUserProfile + responses: + '200': + description: User profile + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/IosUserProfile' } + '401': { $ref: '#/components/responses/Unauthorized' } + patch: + tags: [Auth] + summary: 'iOS: update profile fields' + operationId: updateUserProfile + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/IosUserProfilePatch' } + responses: + '200': + description: Updated profile + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: { $ref: '#/components/schemas/IosUserProfile' } + '401': { $ref: '#/components/responses/Unauthorized' } + '409': { $ref: '#/components/responses/Conflict' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/devices: + post: + tags: [Notifications] + summary: 'iOS: register an APNs device token' + operationId: registerDevice + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/DeviceRegisterInput' } + responses: + '201': + description: Device registered (or token transferred) + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + id: { type: string } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/medications/intake: + get: + tags: [Medications] + summary: 'iOS: today list or compliance buckets' + operationId: listIntakeAggregator + parameters: + - in: query + name: scope + required: true + schema: { type: string, enum: [today, compliance] } + - in: query + name: days + schema: + type: integer + minimum: 1 + maximum: 365 + default: 30 + responses: + '200': + description: Aggregator payload + content: + application/json: + schema: { $ref: '#/components/schemas/ApiEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '422': { $ref: '#/components/responses/Unprocessable' } + post: + tags: [Medications] + summary: 'iOS: update an intake event by id (taken/skipped/snoozed)' + operationId: updateIntakeAggregator + requestBody: + required: true + content: + application/json: + schema: { $ref: '#/components/schemas/IntakeUpdateInput' } + responses: + '200': + description: Updated intake event + content: + application/json: + schema: { $ref: '#/components/schemas/ApiEnvelope' } + '401': { $ref: '#/components/responses/Unauthorized' } + '404': { $ref: '#/components/responses/NotFound' } + '422': { $ref: '#/components/responses/Unprocessable' } + + /api/insights/cards: + get: + tags: [Insights] + summary: 'iOS: rule-based insight cards' + operationId: getInsightCards + responses: + '200': + description: Insight cards + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/InsightCard' } + '401': { $ref: '#/components/responses/Unauthorized' } + + /api/insights/correlations: + get: + tags: [Insights] + summary: 'iOS: cross-metric correlations (placeholder)' + operationId: getInsightCorrelations + description: | + Returns an empty array until the correlations engine is wired up + (Phase 7). Audited via `insights.correlations.empty`. + responses: + '200': + description: Correlations + content: + application/json: + schema: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: array + items: { $ref: '#/components/schemas/InsightCorrelation' } + '401': { $ref: '#/components/responses/Unauthorized' } + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: 'hlk_' + description: | + Long-lived API token issued via `POST /api/tokens`. Accepted on all + user endpoints (interchangeable with the session cookie) and required + on external ingest endpoints (e.g. `/api/ingest/medication`). Tokens + are prefixed `hlk_` and stored as SHA-256 hashes server-side. Bearer + tokens never grant admin access — admin routes require the session + cookie. Permission scopes are enforced from the token's `permissions` + array. + sessionCookie: + type: apiKey + in: cookie + name: healthlog_session + description: | + HttpOnly session cookie set by `/api/auth/login` and the passkey + login flow. Authenticates the web app and (optionally) the iOS + client when used in a web view. + + parameters: + IdPathParam: + in: path + name: id + required: true + schema: + type: string + description: 'CUID identifier.' + LimitParam: + in: query + name: limit + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + OffsetParam: + in: query + name: offset + schema: + type: integer + minimum: 0 + default: 0 + LocaleParam: + in: query + name: locale + schema: + type: string + enum: [de, en] + description: 'Override the resolved locale for narrative text.' + + responses: + BadRequest: + description: Bad request + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + Unauthorized: + description: Authentication required + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + Forbidden: + description: Forbidden + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + NotFound: + description: Not found + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + Conflict: + description: Conflict (e.g. duplicate) + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + Unprocessable: + description: Validation failure + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + RateLimited: + description: Rate limit exceeded + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + ServerError: + description: Internal server error + content: + application/json: + schema: { $ref: '#/components/schemas/ApiError' } + + schemas: + # ─── Envelope / errors ───────────────────────────────────── + ApiEnvelope: + type: object + description: Standard envelope. On success `error` is `null`; on failure `data` is `null` and `error` is a string. + properties: + data: + description: Response payload (typed per-endpoint). + error: + type: [string, 'null'] + required: [data, error] + + ApiError: + type: object + properties: + data: + type: 'null' + error: + type: string + required: [data, error] + + DeletedEnvelope: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + deleted: { type: boolean } + + UpdatedEnvelope: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + updated: { type: boolean } + + PageMeta: + type: object + properties: + total: { type: integer } + limit: { type: integer } + offset: { type: integer } + required: [total, limit, offset] + + # ─── Enums ───────────────────────────────────────────────── + MeasurementType: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + + MeasurementSource: + type: string + enum: [MANUAL, WITHINGS, IMPORT] + + GlucoseContext: + type: string + enum: [FASTING, POSTPRANDIAL, RANDOM, BEDTIME] + + IntakeSource: + type: string + enum: [WEB, API, REMINDER, IMPORT] + + ReminderPhase: + type: string + enum: [GREEN, YELLOW, ORANGE, RED] + + PhaseMode: + type: string + enum: [MINUTES, PERCENT] + + UserRole: + type: string + enum: [ADMIN, USER] + + Gender: + type: string + enum: [MALE, FEMALE] + + MoodLevel: + type: string + enum: [SUPER_GUT, GUT, OKAY, SCHLECHT, LAUSIG] + + MedicationCategory: + type: string + enum: + - BLOOD_PRESSURE + - VITAMIN + - SUPPLEMENT + - PAIN_RELIEF + - ALLERGY + - DIGESTIVE + - THYROID + - HORMONE + - SKIN + - SLEEP_AID + - OTHER + + NotificationChannelType: + type: string + enum: [TELEGRAM, NTFY, WEB_PUSH] + + AiProviderType: + type: string + enum: [OPENAI, ANTHROPIC, LOCAL, CHATGPT_OAUTH] + + InsightSeverity: + type: string + enum: [normal, mild, moderate, severe, info] + + Trend: + type: [string, 'null'] + enum: [up, down, stable, null] + + # ─── Auth DTOs ───────────────────────────────────────────── + RegisterInput: + type: object + properties: + email: { type: string, format: email } + username: + type: string + minLength: 3 + maxLength: 30 + pattern: '^[a-zA-Z0-9_-]+$' + password: + type: string + minLength: 12 + required: [email, username, password] + + LoginInput: + type: object + properties: + email: + type: string + description: Email or username. + password: { type: string } + required: [email, password] + + LoginResponseEnvelope: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + user: + type: object + properties: + id: { type: string } + username: { type: string } + required: [id, username] + token: + type: string + description: | + Long-lived `hlk_*` Bearer token. Only returned when the + request carries `X-Client-Type: native` or a User-Agent + starting with `HealthLog-iOS`. Use it for subsequent + requests as `Authorization: Bearer `. + tokenExpiresAt: + type: string + format: date-time + description: ISO timestamp at which the auto-issued token expires. + + RegisterResponseEnvelope: + allOf: + - $ref: '#/components/schemas/ApiEnvelope' + - type: object + properties: + data: + type: object + properties: + user: + type: object + properties: + id: { type: string } + username: { type: string } + email: { type: [string, 'null'] } + + ProfileInput: + type: object + properties: + email: { type: [string, 'null'], format: email } + heightCm: + type: [number, 'null'] + minimum: 50 + maximum: 300 + dateOfBirth: + type: [string, 'null'] + format: date + gender: + oneOf: + - $ref: '#/components/schemas/Gender' + - type: 'null' + + ChangePasswordInput: + type: object + properties: + currentPassword: { type: string } + newPassword: { type: string, minLength: 12 } + confirmPassword: { type: string } + required: [currentPassword, newPassword, confirmPassword] + + User: + type: object + description: Public-facing user shape. + properties: + id: { type: string } + username: { type: string } + email: { type: [string, 'null'], format: email } + role: { $ref: '#/components/schemas/UserRole' } + heightCm: { type: [number, 'null'] } + dateOfBirth: { type: [string, 'null'], format: date-time } + gender: + oneOf: + - $ref: '#/components/schemas/Gender' + - type: 'null' + timezone: { type: string } + required: [id, username, role, timezone] + + CurrentUser: + allOf: + - $ref: '#/components/schemas/User' + - type: object + properties: + onboardingCompletedAt: + type: [string, 'null'] + format: date-time + gravatarUrl: + type: [string, 'null'] + glucoseUnit: + type: [string, 'null'] + enum: ['mg/dL', 'mmol/L', null] + + # ─── Passkey DTOs ────────────────────────────────────────── + PasskeyRegisterOptions: + type: object + description: SimpleWebAuthn `PublicKeyCredentialCreationOptionsJSON` plus a server-issued challenge id. + properties: + options: + type: object + additionalProperties: true + description: WebAuthn registration options. + challengeId: { type: string } + required: [options, challengeId] + + PasskeyLoginOptions: + type: object + properties: + options: + type: object + additionalProperties: true + description: WebAuthn authentication options. + challengeId: { type: string } + required: [options, challengeId] + + PasskeyVerifyRegistrationInput: + type: object + properties: + challengeId: { type: string } + credential: + type: object + additionalProperties: true + description: '`RegistrationResponseJSON` from `@simplewebauthn/browser`.' + required: [challengeId, credential] + + PasskeyVerifyLoginInput: + type: object + properties: + challengeId: { type: string } + credential: + type: object + additionalProperties: true + description: '`AuthenticationResponseJSON` from `@simplewebauthn/browser`.' + required: [challengeId, credential] + + PasskeySummary: + type: object + properties: + id: { type: string } + name: { type: string } + credentialDeviceType: { type: string } + credentialBackedUp: { type: boolean } + createdAt: { type: string, format: date-time } + required: [id, name, credentialDeviceType, credentialBackedUp, createdAt] + + # ─── Measurement DTOs ────────────────────────────────────── + Measurement: + type: object + properties: + id: { type: string } + userId: { type: string } + type: { $ref: '#/components/schemas/MeasurementType' } + value: { type: number } + unit: { type: string } + source: { $ref: '#/components/schemas/MeasurementSource' } + measuredAt: { type: string, format: date-time } + notes: { type: [string, 'null'], maxLength: 25 } + externalId: { type: [string, 'null'] } + glucoseContext: + oneOf: + - $ref: '#/components/schemas/GlucoseContext' + - type: 'null' + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + required: [id, userId, type, value, unit, source, measuredAt, createdAt, updatedAt] + + CreateMeasurementInput: + type: object + properties: + type: { $ref: '#/components/schemas/MeasurementType' } + value: { type: number } + measuredAt: { type: string, format: date-time } + notes: { type: string, maxLength: 25 } + source: + allOf: [{ $ref: '#/components/schemas/MeasurementSource' }] + default: MANUAL + glucoseContext: + $ref: '#/components/schemas/GlucoseContext' + required: [type, value, measuredAt] + + UpdateMeasurementInput: + type: object + properties: + value: { type: number, minimum: 0, maximum: 500000 } + measuredAt: { type: string, format: date-time } + notes: { type: [string, 'null'], maxLength: 25 } + + # ─── Medication DTOs ─────────────────────────────────────── + MedicationSchedule: + type: object + properties: + id: { type: string } + medicationId: { type: string } + windowStart: + type: string + pattern: '^([01]\d|2[0-3]):([0-5]\d)$' + description: HH:mm + windowEnd: + type: string + pattern: '^([01]\d|2[0-3]):([0-5]\d)$' + label: { type: [string, 'null'] } + dose: { type: [string, 'null'], description: 'Per-schedule dose override' } + daysOfWeek: + type: [string, 'null'] + description: 'Serialized recurrence (CSV of 0..6, optional intervalWeeks suffix).' + required: [id, medicationId, windowStart, windowEnd] + + Medication: + type: object + properties: + id: { type: string } + userId: { type: string } + name: { type: string } + dose: { type: string } + active: { type: boolean } + notificationsEnabled: { type: boolean } + pausedAt: { type: [string, 'null'], format: date-time } + snoozedUntil: { type: [string, 'null'], format: date-time } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + required: [id, userId, name, dose, active, notificationsEnabled, createdAt, updatedAt] + + MedicationWithSchedules: + allOf: + - $ref: '#/components/schemas/Medication' + - type: object + properties: + schedules: + type: array + items: { $ref: '#/components/schemas/MedicationSchedule' } + category: { $ref: '#/components/schemas/MedicationCategory' } + lastTakenAt: + type: [string, 'null'] + format: date-time + todayEventCount: + type: integer + + MedicationScheduleInput: + type: object + properties: + windowStart: { type: string, pattern: '^([01]\d|2[0-3]):([0-5]\d)$' } + windowEnd: { type: string, pattern: '^([01]\d|2[0-3]):([0-5]\d)$' } + label: { type: string, maxLength: 50 } + dose: { type: string, maxLength: 50 } + daysOfWeek: + type: array + items: { type: integer, minimum: 0, maximum: 6 } + intervalWeeks: + type: integer + minimum: 1 + maximum: 4 + required: [windowStart, windowEnd] + + CreateMedicationInput: + type: object + properties: + name: { type: string, minLength: 1, maxLength: 100 } + dose: { type: string, minLength: 1, maxLength: 50 } + category: { $ref: '#/components/schemas/MedicationCategory' } + schedules: + type: array + minItems: 1 + items: { $ref: '#/components/schemas/MedicationScheduleInput' } + required: [name, dose, schedules] + + UpdateMedicationInput: + type: object + properties: + name: { type: string, minLength: 1, maxLength: 100 } + dose: { type: string, minLength: 1, maxLength: 50 } + category: { $ref: '#/components/schemas/MedicationCategory' } + active: { type: boolean } + notificationsEnabled: { type: boolean } + schedules: + type: array + items: { $ref: '#/components/schemas/MedicationScheduleInput' } + + MedicationIntakeEvent: + type: object + properties: + id: { type: string } + userId: { type: string } + medicationId: { type: string } + scheduledFor: { type: string, format: date-time } + takenAt: { type: [string, 'null'], format: date-time } + skipped: { type: boolean } + source: { $ref: '#/components/schemas/IntakeSource' } + idempotencyKey: { type: [string, 'null'] } + createdAt: { type: string, format: date-time } + required: [id, userId, medicationId, scheduledFor, skipped, source, createdAt] + + CreateIntakeInput: + type: object + properties: + scheduledFor: { type: string, format: date-time } + takenAt: { type: string, format: date-time } + skipped: { type: boolean, default: false } + idempotencyKey: { type: string, maxLength: 128 } + + UpdateIntakeInput: + type: object + properties: + takenAt: { type: [string, 'null'], format: date-time } + skipped: { type: boolean } + scheduledFor: { type: string, format: date-time } + + IntakeImportEntry: + type: object + properties: + datum: + type: string + pattern: '^\d{4}-\d{2}-\d{2}$' + description: YYYY-MM-DD + uhrzeit: + type: string + pattern: '^\d{2}:\d{2}:\d{2}$' + description: HH:mm:ss + zaehler: + oneOf: + - { type: integer } + - { type: string } + required: [datum, uhrzeit] + + IntakeImportResult: + type: object + properties: + imported: { type: integer } + skippedDuplicates: { type: integer } + skippedInvalid: { type: integer } + + ExternalIntakeInput: + type: object + properties: + medicationName: { type: string, maxLength: 200 } + takenAt: { type: string, format: date-time } + idempotencyKey: { type: string, maxLength: 128 } + required: [medicationName, idempotencyKey] + + DailyComplianceEntry: + type: object + properties: + expected: { type: integer } + taken: { type: integer } + skipped: { type: integer } + onTime: { type: integer } + late: { type: integer } + veryLate: { type: integer } + + ComplianceSummary: + type: object + properties: + rate: { type: number, description: 'Fraction 0..1' } + taken: { type: integer } + skipped: { type: integer } + missed: { type: integer } + streak: { type: integer } + + MedicationComplianceResponse: + type: object + properties: + compliance7: { $ref: '#/components/schemas/ComplianceSummary' } + compliance30: { $ref: '#/components/schemas/ComplianceSummary' } + dailyCompliance: + type: object + additionalProperties: { $ref: '#/components/schemas/DailyComplianceEntry' } + + ReminderPhaseConfig: + type: object + properties: + greenValue: { type: integer, default: 60 } + greenMode: { allOf: [{ $ref: '#/components/schemas/PhaseMode' }], default: MINUTES } + yellowValue: { type: integer, default: 30 } + yellowMode: { allOf: [{ $ref: '#/components/schemas/PhaseMode' }], default: MINUTES } + orangeValue: { type: integer, default: 0 } + orangeMode: { allOf: [{ $ref: '#/components/schemas/PhaseMode' }], default: MINUTES } + redValue: { type: integer, default: 240 } + redMode: { allOf: [{ $ref: '#/components/schemas/PhaseMode' }], default: MINUTES } + + MedicationApiEndpointResponse: + type: object + properties: + enabled: { type: boolean } + activeTokenCount: { type: integer } + token: + type: [string, 'null'] + description: 'Plaintext token, only returned once on creation.' + created: { type: boolean } + revokedTokenCount: { type: integer } + + IntakeSummaryPoint: + type: object + properties: + date: + type: string + description: YYYY-MM-DD (Berlin local). + total: { type: integer } + medications: + type: object + additionalProperties: { type: integer } + required: [date, total] + + IntakeSummaryResponse: + type: object + properties: + points: + type: array + items: { $ref: '#/components/schemas/IntakeSummaryPoint' } + medications: + type: array + items: { type: string } + + # ─── Mood DTOs ───────────────────────────────────────────── + MoodEntry: + type: object + properties: + id: { type: string } + userId: { type: string } + date: + type: string + description: YYYY-MM-DD + mood: { $ref: '#/components/schemas/MoodLevel' } + score: + type: integer + minimum: 1 + maximum: 5 + tags: + type: array + items: { type: string } + source: { type: string, default: MANUAL } + moodLoggedAt: { type: string, format: date-time } + syncedAt: { type: string, format: date-time } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + required: [id, userId, date, mood, score, source, moodLoggedAt] + + CreateMoodEntryInput: + type: object + properties: + mood: { $ref: '#/components/schemas/MoodLevel' } + moodLoggedAt: { type: string, format: date-time } + tags: + type: array + items: { type: string } + source: { type: string } + required: [mood, moodLoggedAt] + + UpdateMoodEntryInput: + type: object + properties: + mood: { $ref: '#/components/schemas/MoodLevel' } + moodLoggedAt: { type: string, format: date-time } + tags: + type: array + items: { type: string } + + MoodAnalytics: + type: object + properties: + entries: + type: array + items: + type: object + properties: + date: { type: string } + score: { type: number } + samples: { type: integer } + summary: + type: object + additionalProperties: true + + # ─── Dashboard DTOs ──────────────────────────────────────── + DashboardWidget: + type: object + properties: + id: + type: string + enum: [weight, bp, pulse, bodyFat, mood, medications, sleep, steps, glucose, bpInTarget] + visible: { type: boolean } + order: { type: integer, minimum: 0, maximum: 99 } + required: [id, visible, order] + + DashboardLayout: + type: object + properties: + version: { type: integer, enum: [1] } + widgets: + type: array + minItems: 1 + maxItems: 20 + items: { $ref: '#/components/schemas/DashboardWidget' } + required: [version, widgets] + + # ─── Insights DTOs ───────────────────────────────────────── + Classification: + type: object + properties: + category: { type: string } + color: { type: string } + severity: { $ref: '#/components/schemas/InsightSeverity' } + + SummaryStats: + type: object + properties: + latest: { type: [number, 'null'] } + avg7: { type: [number, 'null'] } + avg30: { type: [number, 'null'] } + min: { type: [number, 'null'] } + max: { type: [number, 'null'] } + count: { type: integer } + slope30: + type: [object, 'null'] + properties: + slope: { type: number } + r2: { type: number } + anomalyCount: { type: integer } + + CorrelationResult: + type: [object, 'null'] + properties: + r: { type: number } + strength: { type: string } + n: { type: integer } + + Alert: + type: object + properties: + severity: { $ref: '#/components/schemas/InsightSeverity' } + message: { type: string } + type: { type: string } + + BpTargets: + type: [object, 'null'] + properties: + sysLow: { type: integer } + sysHigh: { type: integer } + diaLow: { type: integer } + diaHigh: { type: integer } + + ComprehensiveInsights: + type: object + properties: + summaries: + type: object + additionalProperties: { $ref: '#/components/schemas/SummaryStats' } + bmi: { type: [number, 'null'] } + bmiClassification: + oneOf: + - $ref: '#/components/schemas/Classification' + - type: 'null' + bpClassification: + oneOf: + - $ref: '#/components/schemas/Classification' + - type: 'null' + bpPctInTarget: { type: [integer, 'null'] } + bpTargets: { $ref: '#/components/schemas/BpTargets' } + weightBpCorrelation: { $ref: '#/components/schemas/CorrelationResult' } + scatterData: + type: array + items: + type: object + properties: + weight: { type: number } + sysBP: { type: number } + bpMedicationCorrelation: + type: [object, 'null'] + properties: + r: { type: number } + strength: { type: string } + n: { type: integer } + medicationCount: { type: integer } + bpMedicationScatterData: + type: array + items: + type: object + properties: + continuityPct: { type: integer } + sysBP: { type: number } + moodSummary: + oneOf: + - $ref: '#/components/schemas/SummaryStats' + - type: 'null' + moodBpCorrelation: { $ref: '#/components/schemas/CorrelationResult' } + moodBpScatterData: + type: array + items: + type: object + properties: + mood: { type: number } + sysBP: { type: number } + moodWeightCorrelation: { $ref: '#/components/schemas/CorrelationResult' } + moodWeightScatterData: + type: array + items: + type: object + properties: + mood: { type: number } + weight: { type: number } + moodPulseCorrelation: { $ref: '#/components/schemas/CorrelationResult' } + moodPulseScatterData: + type: array + items: + type: object + properties: + mood: { type: number } + pulse: { type: number } + medications: + type: array + items: + type: object + properties: + id: { type: string } + name: { type: string } + dose: { type: string } + category: { $ref: '#/components/schemas/MedicationCategory' } + compliance7: { type: number } + compliance30: { type: number } + streak: { type: integer } + taken7: { type: integer } + skipped7: { type: integer } + missed7: { type: integer } + alerts: + type: array + items: { $ref: '#/components/schemas/Alert' } + hasProvider: { type: boolean } + dataSpanDays: { type: integer } + totalMeasurements: { type: integer } + + InsightTargetItem: + type: object + properties: + type: { type: string } + label: { type: string } + current: { type: [number, 'null'] } + average30: { type: [number, 'null'] } + trend: { $ref: '#/components/schemas/Trend' } + unit: { type: string } + range: + type: [object, 'null'] + properties: + min: { type: number } + max: { type: number } + classification: + oneOf: + - $ref: '#/components/schemas/Classification' + - type: 'null' + source: { type: string } + details: + type: object + properties: + medications: + type: array + items: + type: object + properties: + name: { type: string } + compliance7: { type: number } + compliance30: { type: number } + + InsightTargetsResponse: + type: object + properties: + targets: + type: array + items: { $ref: '#/components/schemas/InsightTargetItem' } + bpDiastolic: + type: object + properties: + current: { type: [number, 'null'] } + average30: { type: [number, 'null'] } + range: + type: [object, 'null'] + properties: + min: { type: number } + max: { type: number } + profile: + type: object + properties: + heightCm: { type: [number, 'null'] } + age: { type: [integer, 'null'] } + gender: + oneOf: + - $ref: '#/components/schemas/Gender' + - type: 'null' + glucoseUnit: { type: string } + + InsightStatusBundle: + type: object + description: Narrative status payload (shape varies per status route). + additionalProperties: true + + InsightGenerateResult: + type: object + properties: + insights: + type: object + additionalProperties: true + cached: { type: boolean } + cachedAt: { type: string, format: date-time } + + InsightsSettings: + type: object + properties: + codexStatus: { type: string, enum: [connected, disconnected] } + codexConnectedAt: { type: [string, 'null'], format: date-time } + hasAdminKey: { type: boolean } + privacyMode: { type: string, enum: [aggregated, raw] } + lastInsightAt: { type: [string, 'null'], format: date-time } + + # ─── Achievements ────────────────────────────────────────── + Achievement: + type: object + properties: + id: { type: string } + title: { type: string } + description: { type: string } + target: { type: integer } + progress: { type: integer } + unlocked: { type: boolean } + completedAt: + type: [string, 'null'] + format: date-time + + AchievementsResult: + type: object + properties: + achievements: + type: array + items: { $ref: '#/components/schemas/Achievement' } + unlockedCount: { type: integer } + totalCount: { type: integer } + + # ─── Doctor report ───────────────────────────────────────── + DoctorReportResponse: + type: object + properties: + period: + type: object + properties: + days: { type: integer } + since: { type: string, format: date-time } + patient: + type: object + properties: + username: { type: [string, 'null'] } + dateOfBirth: { type: [string, 'null'], format: date-time } + gender: + oneOf: + - $ref: '#/components/schemas/Gender' + - type: 'null' + heightCm: { type: [number, 'null'] } + measurements: + type: object + additionalProperties: + type: array + items: + type: object + properties: + value: { type: number } + measuredAt: { type: string, format: date-time } + stats: + type: object + additionalProperties: + type: object + properties: + avg: { type: number } + min: { type: number } + max: { type: number } + count: { type: integer } + latest: { type: number } + glucoseStats: + type: object + additionalProperties: + type: object + properties: + avg: { type: number } + min: { type: number } + max: { type: number } + count: { type: integer } + latest: { type: number } + glucoseRanges: + type: object + additionalProperties: + type: object + properties: + min: { type: number } + max: { type: number } + glucoseUnit: { type: string } + bmi: { type: [number, 'null'] } + compliance: + type: object + additionalProperties: + type: object + properties: + total: { type: integer } + taken: { type: integer } + skipped: { type: integer } + missed: { type: integer } + medications: + type: array + items: + type: object + properties: + name: { type: string } + dose: { type: string } + schedules: + type: array + items: + type: object + properties: + windowStart: { type: string } + windowEnd: { type: string } + label: { type: [string, 'null'] } + mood: + type: [object, 'null'] + properties: + avg: { type: number } + min: { type: number } + max: { type: number } + count: { type: integer } + distribution: + type: object + additionalProperties: { type: integer } + + # ─── Onboarding ──────────────────────────────────────────── + OnboardingInput: + type: object + properties: + heightCm: { type: number, minimum: 50, maximum: 300 } + dateOfBirth: { type: string, format: date } + gender: { $ref: '#/components/schemas/Gender' } + + # ─── Health probe ────────────────────────────────────────── + HealthStatus: + type: object + properties: + status: { type: string, enum: [ok, degraded] } + timestamp: { type: string, format: date-time } + database: { type: string } + worker: { type: string } + workerLastHeartbeat: { type: string, format: date-time } + + # ─── Tokens ──────────────────────────────────────────────── + ApiTokenSummary: + type: object + properties: + id: { type: string } + name: { type: string } + permissions: + type: array + items: { type: string } + lastUsedAt: { type: [string, 'null'], format: date-time } + expiresAt: { type: [string, 'null'], format: date-time } + createdAt: { type: string, format: date-time } + revoked: { type: boolean } + + CreateTokenInput: + type: object + properties: + name: { type: string, minLength: 1, maxLength: 100 } + expiresInDays: { type: integer, minimum: 1, maximum: 365 } + required: [name] + + # ─── Notifications ───────────────────────────────────────── + NotificationChannel: + type: object + properties: + id: { type: string } + type: { $ref: '#/components/schemas/NotificationChannelType' } + label: { type: string } + enabled: { type: boolean } + globallyEnabled: { type: boolean } + + NotificationPreference: + type: object + properties: + channelId: { type: string } + eventType: { type: string } + enabled: { type: boolean } + + NotificationPreferencesResponse: + type: object + properties: + channels: + type: array + items: { $ref: '#/components/schemas/NotificationChannel' } + preferences: + type: array + items: { $ref: '#/components/schemas/NotificationPreference' } + eventTypes: + type: array + items: { type: string } + + UpdateNotificationPreferenceInput: + type: object + properties: + channelId: { type: string } + eventType: { type: string } + enabled: { type: boolean } + required: [channelId, eventType, enabled] + + WebPushSubscribeInput: + type: object + properties: + endpoint: { type: string, format: uri } + keys: + type: object + properties: + p256dh: { type: string } + auth: { type: string } + required: [p256dh, auth] + required: [endpoint, keys] + + # ─── User config ─────────────────────────────────────────── + UserAiProvider: + type: object + properties: + provider: + oneOf: + - $ref: '#/components/schemas/AiProviderType' + - type: 'null' + model: { type: [string, 'null'] } + baseUrl: { type: [string, 'null'] } + hasAnthropicKey: { type: boolean } + anthropicKeyPreview: { type: [string, 'null'] } + hasLocalKey: { type: boolean } + + UpdateUserAiProviderInput: + type: object + properties: + provider: + oneOf: + - $ref: '#/components/schemas/AiProviderType' + - type: 'null' + model: { type: [string, 'null'] } + baseUrl: { type: [string, 'null'] } + anthropicKey: { type: [string, 'null'] } + localKey: { type: [string, 'null'] } + + ThresholdRange: + type: object + properties: + greenMin: { type: number } + greenMax: { type: number } + orangeMin: { type: number } + orangeMax: { type: number } + redMin: { type: number } + redMax: { type: number } + + EffectiveRange: + type: object + properties: + range: + oneOf: + - $ref: '#/components/schemas/ThresholdRange' + - type: 'null' + isOverride: { type: boolean } + defaultRange: + oneOf: + - $ref: '#/components/schemas/ThresholdRange' + - type: 'null' + + ThresholdOverrides: + type: object + description: 'Map of metric name → `{ min, max }` override.' + additionalProperties: + type: object + properties: + min: { type: number } + max: { type: number } + + ThresholdsResponse: + type: object + properties: + effective: + type: object + additionalProperties: { $ref: '#/components/schemas/EffectiveRange' } + overrides: { $ref: '#/components/schemas/ThresholdOverrides' } + + # ─── Analytics ───────────────────────────────────────────── + AnalyticsResponse: + type: object + properties: + summaries: + type: object + additionalProperties: { $ref: '#/components/schemas/SummaryStats' } + bmi: { type: [number, 'null'] } + bpInTargetPct: { type: [integer, 'null'] } + glucoseByContext: + type: object + additionalProperties: { $ref: '#/components/schemas/SummaryStats' } + + AiTestResult: + type: object + properties: + ok: { type: boolean } + providerType: { $ref: '#/components/schemas/AiProviderType' } + model: { type: string } + tokensUsed: + type: object + properties: + input: { type: integer } + output: { type: integer } + sample: { type: string } + + # ─── Export / Import ─────────────────────────────────────── + ImportInput: + type: object + properties: + measurements: + type: array + maxItems: 10000 + items: + type: object + properties: + type: { $ref: '#/components/schemas/MeasurementType' } + value: { type: number } + unit: { type: string } + measuredAt: { type: string, format: date-time } + source: { type: string } + notes: { type: string } + required: [type, value, unit, measuredAt] + moodEntries: + type: array + maxItems: 10000 + items: + type: object + properties: + date: { type: string } + mood: { $ref: '#/components/schemas/MoodLevel' } + score: { type: integer, minimum: 1, maximum: 5 } + tags: { type: string } + loggedAt: { type: string, format: date-time } + required: [date, mood, score] + + ImportResult: + type: object + properties: + measurements: { type: integer } + moodEntries: { type: integer } + skipped: { type: integer } + + # ─── Audit log ───────────────────────────────────────────── + AuditLogEntry: + type: object + properties: + id: { type: string } + action: { type: string } + ipAddress: { type: [string, 'null'] } + location: { type: [string, 'null'] } + details: { type: [string, 'null'], description: 'JSON string' } + createdAt: { type: string, format: date-time } + + AuditLogResponse: + type: object + properties: + entries: + type: array + items: { $ref: '#/components/schemas/AuditLogEntry' } + meta: { $ref: '#/components/schemas/PageMeta' } + + # ─── Feedback ────────────────────────────────────────────── + FeedbackCategory: + type: string + enum: [BUG, FEATURE_REQUEST, QUESTION, OTHER] + + FeedbackStatus: + type: string + enum: [OPEN, ACKNOWLEDGED, RESOLVED, ARCHIVED] + + FeedbackSummary: + type: object + properties: + id: { type: string } + category: { $ref: '#/components/schemas/FeedbackCategory' } + subject: { type: string } + status: { $ref: '#/components/schemas/FeedbackStatus' } + adminNote: { type: [string, 'null'] } + gitHubIssueUrl: { type: [string, 'null'], format: uri } + createdAt: { type: string, format: date-time } + updatedAt: { type: string, format: date-time } + + CreateFeedbackInput: + type: object + properties: + category: { $ref: '#/components/schemas/FeedbackCategory' } + subject: { type: string } + description: { type: string } + screenshot: + type: string + description: 'Base64-encoded PNG/JPG (≤5 MB).' + metadata: + type: object + additionalProperties: true + required: [category, subject, description] + + BugReportInput: + type: object + properties: + description: { type: string, minLength: 10, maxLength: 5000 } + screenshot: + type: string + description: '`data:image/(png|jpeg|webp|gif);base64,...` data URL.' + required: [description] + + # ─── Settings ────────────────────────────────────────────── + GlobalServicesFlags: + type: object + properties: + telegram: { type: boolean } + ntfy: { type: boolean } + webPush: { type: boolean } + api: { type: boolean } + moodLog: { type: boolean } + + MoodLogSettingsInput: + type: object + properties: + url: { type: [string, 'null'], format: uri } + apiKey: { type: [string, 'null'] } + enabled: { type: boolean } + webhookSecret: { type: [string, 'null'] } + + NtfySettings: + type: object + properties: + configured: { type: boolean } + enabled: { type: boolean } + topic: { type: [string, 'null'] } + serverUrl: { type: [string, 'null'] } + + NtfySettingsInput: + type: object + properties: + topic: { type: [string, 'null'] } + serverUrl: { type: [string, 'null'], format: uri } + enabled: { type: boolean } + accessToken: { type: [string, 'null'] } + + TelegramSettings: + type: object + properties: + configured: { type: boolean } + enabled: { type: boolean } + chatId: { type: [string, 'null'] } + + TelegramSettingsInput: + type: object + properties: + botToken: { type: [string, 'null'] } + chatId: { type: [string, 'null'] } + enabled: { type: boolean } + + ReminderThresholds: + type: object + properties: + reminderLateMinutes: { type: integer } + reminderMissedMinutes: { type: integer } + + # ─── Withings + moodLog status ───────────────────────────── + WithingsCredentialsInput: + type: object + properties: + clientId: { type: string } + clientSecret: { type: string } + required: [clientId, clientSecret] + + WithingsStatus: + type: object + properties: + connected: { type: boolean } + configured: { type: boolean } + lastSyncedAt: { type: [string, 'null'], format: date-time } + connectedAt: { type: string, format: date-time } + tokenExpired: { type: boolean } + tokenRefreshFailed: { type: boolean } + tokenExpiresAt: { type: string, format: date-time } + + MoodLogStatus: + type: object + properties: + configured: { type: boolean } + enabled: { type: boolean } + lastSyncedAt: { type: [string, 'null'], format: date-time } + entryCount: { type: integer } + webhookSecret: { type: [string, 'null'] } + + PublicMonitoringSettings: + type: object + properties: + umamiEnabled: { type: boolean } + umamiScriptUrl: { type: [string, 'null'] } + umamiWebsiteId: { type: [string, 'null'] } + glitchtipEnabled: { type: boolean } + glitchtipDsn: { type: [string, 'null'] } + glitchtipEnvironment: { type: [string, 'null'] } + + # ─── iOS adapter DTOs (v1.3) ────────────────────────────── + MetricKind: + type: string + enum: + - weight + - bloodPressure + - pulse + - bodyFat + - glucose + - sleep + - steps + + InsightSeverityIos: + type: string + enum: [alert, caution, info, good] + + DashboardSummaryGreeting: + type: object + properties: + salutation: { type: string } + date: { type: string, format: date-time } + required: [salutation, date] + + DashboardSummaryStreak: + type: object + properties: + currentDays: { type: integer } + longest: { type: integer } + label: { type: string } + required: [currentDays, longest, label] + + DashboardSummaryCompliance: + type: object + properties: + scheduledToday: { type: integer } + takenToday: { type: integer } + required: [scheduledToday, takenToday] + + DashboardMetricCard: + type: object + properties: + id: { type: string } + kind: { $ref: '#/components/schemas/MetricKind' } + title: { type: string } + latestValue: { type: [number, 'null'] } + secondaryValue: { type: [number, 'null'] } + unit: { type: string } + trend: + type: string + enum: [up, down, flat, unknown] + sparkline: + type: array + items: { type: number } + updatedAt: { type: [string, 'null'], format: date-time } + required: [id, kind, title, unit, trend, sparkline] + + DashboardSummary: + type: object + properties: + greeting: { $ref: '#/components/schemas/DashboardSummaryGreeting' } + streak: { $ref: '#/components/schemas/DashboardSummaryStreak' } + compliance: { $ref: '#/components/schemas/DashboardSummaryCompliance' } + highlightInsight: + oneOf: + - { type: 'null' } + - { $ref: '#/components/schemas/InsightCard' } + metrics: + type: array + items: { $ref: '#/components/schemas/DashboardMetricCard' } + lastUpdated: { type: string, format: date-time } + required: [greeting, streak, compliance, metrics, lastUpdated] + + MeasurementSeriesPoint: + type: object + properties: + id: { type: string } + at: { type: string, format: date-time } + value: { type: number } + secondary: { type: [number, 'null'] } + required: [id, at, value] + + MeasurementSeriesStats: + type: object + properties: + mean: { type: number } + min: { type: number } + max: { type: number } + stdDev: { type: number } + count: { type: integer } + required: [mean, min, max, stdDev, count] + + MeasurementSeries: + type: object + properties: + kind: { $ref: '#/components/schemas/MetricKind' } + points: + type: array + items: { $ref: '#/components/schemas/MeasurementSeriesPoint' } + stats: { $ref: '#/components/schemas/MeasurementSeriesStats' } + required: [kind, points, stats] + + HealthKitDirection: + type: string + enum: [bidirectional, readOnly, writeOnly, disabled] + + HealthKitEntry: + type: object + properties: + id: { type: string } + kind: { type: string } + direction: { $ref: '#/components/schemas/HealthKitDirection' } + enabled: { type: boolean } + required: [id, kind, direction, enabled] + + HealthKitConfig: + type: object + properties: + entries: + type: array + items: { $ref: '#/components/schemas/HealthKitEntry' } + lastSyncedAt: { type: [string, 'null'], format: date-time } + required: [entries] + + HealthKitConfigPatch: + type: object + properties: + entries: + type: array + maxItems: 50 + items: + type: object + properties: + id: { type: string } + kind: { type: string } + direction: { $ref: '#/components/schemas/HealthKitDirection' } + enabled: { type: boolean } + required: [id, direction] + required: [entries] + + IosUserProfile: + type: object + properties: + username: { type: string } + displayName: { type: [string, 'null'] } + email: { type: [string, 'null'], format: email } + dateOfBirth: { type: [string, 'null'], format: date-time } + gender: { $ref: '#/components/schemas/Gender' } + heightCm: { type: [number, 'null'] } + locale: { type: [string, 'null'] } + timezone: { type: string } + required: [username, timezone] + + IosUserProfilePatch: + type: object + properties: + displayName: { type: [string, 'null'], minLength: 1, maxLength: 80 } + email: { type: [string, 'null'], format: email } + dateOfBirth: { type: [string, 'null'] } + gender: + oneOf: + - { type: 'null' } + - { $ref: '#/components/schemas/Gender' } + heightCm: { type: [number, 'null'], minimum: 50, maximum: 300 } + locale: + type: [string, 'null'] + enum: [de, en, null] + timezone: { type: string, minLength: 1, maxLength: 64 } + + DeviceRegisterInput: + type: object + properties: + token: { type: string, minLength: 8, maxLength: 512 } + bundleId: { type: string, minLength: 1, maxLength: 128 } + locale: { type: string } + appVersion: { type: string } + model: { type: string } + required: [token, bundleId] + + IntakeUpdateInput: + type: object + properties: + intakeId: { type: string } + status: + type: string + enum: [taken, skipped, snoozed] + takenAt: { type: string, format: date-time } + snoozedUntil: { type: string, format: date-time } + required: [intakeId, status] + + InsightRecommendation: + type: object + properties: + id: { type: string } + label: { type: string } + actionURL: { type: [string, 'null'] } + required: [id, label] + + InsightCard: + type: object + properties: + id: { type: string } + title: { type: string } + summary: { type: string } + body: { type: [string, 'null'] } + severity: { $ref: '#/components/schemas/InsightSeverityIos' } + recommendations: + type: array + items: { $ref: '#/components/schemas/InsightRecommendation' } + generatedAt: { type: string, format: date-time } + provider: { type: string } + required: [id, title, summary, severity, recommendations, generatedAt, provider] + + InsightCorrelation: + type: object + properties: + id: { type: string } + leftMetric: { type: string } + rightMetric: { type: string } + coefficient: { type: number } + sampleSize: { type: integer } + required: [id, leftMetric, rightMetric, coefficient, sampleSize] + + # ─── Admin DTOs ──────────────────────────────────────────── + AdminUpdateUserInput: + type: object + properties: + username: { type: string, minLength: 3, maxLength: 30 } + email: { type: [string, 'null'], format: email } + role: { $ref: '#/components/schemas/UserRole' }