From a8b233bf862424e3fb7ac24b788e45d00f8f5dda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 11 May 2026 00:56:32 +0200 Subject: [PATCH] =?UTF-8?q?chore(deps):=20add=20zod-openapi=20+=20yaml@2?= =?UTF-8?q?=20=E2=80=94=20OpenAPI=20generator=20base?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the OpenAPI 3.1 generator scaffolding ahead of the iOS-touched route registrations. `zod-openapi` (samchungy variant, picked in W1 stream-3) reads Zod v4's native `.meta()` so schemas can stay in their original validation files; `yaml@2` with `sortMapEntries: true` keeps the emitted spec stable across re-runs. `pnpm openapi:generate` overwrites `docs/api/openapi.yaml` from the registry; `pnpm openapi:check` exits non-zero on drift. The legacy hand-maintained spec is preserved at `docs/api/openapi-v1422-legacy.yaml` so the iOS contract reference doesn't disappear during the incremental migration. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/api/openapi-v1422-legacy.yaml | 5468 ++++++++++++++++++++++++++++ docs/api/openapi.yaml | 5468 +--------------------------- package.json | 6 +- pnpm-lock.yaml | 16 + scripts/check-openapi.ts | 62 + scripts/generate-openapi.ts | 35 + src/lib/openapi/registry.ts | 76 + src/lib/openapi/routes.ts | 22 + 8 files changed, 5697 insertions(+), 5456 deletions(-) create mode 100644 docs/api/openapi-v1422-legacy.yaml create mode 100644 scripts/check-openapi.ts create mode 100644 scripts/generate-openapi.ts create mode 100644 src/lib/openapi/registry.ts create mode 100644 src/lib/openapi/routes.ts diff --git a/docs/api/openapi-v1422-legacy.yaml b/docs/api/openapi-v1422-legacy.yaml new file mode 100644 index 000000000..f0715aa19 --- /dev/null +++ b/docs/api/openapi-v1422-legacy.yaml @@ -0,0 +1,5468 @@ +openapi: 3.1.0 + +info: + title: HealthLog API + version: 1.4.0 + description: | + REST API for HealthLog — a personal health-tracking PWA covering weight, + blood pressure, pulse, mood, medication compliance, blood glucose, body + composition (total body water + bone mass), pulse oximetry (SpO2), 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" } + + /api/admin/status-overview: + get: + tags: [Admin] + security: + - sessionCookie: [] + summary: "Internal: aggregator for the 6-card status grid (v1.4)" + description: | + Returns the precomputed status payload the admin top strip + renders — users / integrations / monitoring / backups / + maintenance / audit-log summaries with severity already + resolved server-side. Single batched query, no N+1. + operationId: adminStatusOverview + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/admin/backup/test: + post: + tags: [Admin] + security: + - sessionCookie: [] + summary: "Internal: probe the configured S3-compatible backup target (v1.4)" + description: | + Round-trips a small object against the bucket using the + configured `BACKUP_S3_*` credentials. Returns latency and the + sanitised endpoint host on success; never echoes the access key. + operationId: adminBackupTest + 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" } + + # ─── Public + v1.4 additions ───────────────────────────────────────── + + /api/version: + get: + tags: [Public] + summary: "Public: running version + build SHA + license" + description: | + Public, unauthenticated. Returns + `{ version, buildSha, builtAt, license, repository, changelog, docs }`. + Used by the docker container healthcheck and the Settings → + About surface's "Check for updates" button. + operationId: getVersion + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + + /api/integrations/withings/test: + post: + tags: [Withings] + security: + - sessionCookie: [] + summary: "Probe the saved Withings OAuth connection (v1.4)" + operationId: withingsTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/integrations/moodlog/test: + post: + tags: [Moodlog] + security: + - sessionCookie: [] + summary: "Probe moodLog.app reachability (v1.4)" + description: | + SSRF-guarded HEAD against the configured moodLog endpoint. + Returns `{ ok, latency_ms, errorCode? }` so the UI can + localise the failure message. + operationId: moodlogTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/notifications/web-push/test: + post: + tags: [Notifications] + security: + - sessionCookie: [] + summary: "Send a test Web Push to the current user (v1.4)" + operationId: webPushTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/monitoring/glitchtip/test: + post: + tags: [Monitoring] + security: + - sessionCookie: [] + summary: "Probe the Glitchtip ingest endpoint (v1.4)" + operationId: glitchtipTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/monitoring/umami/test: + post: + tags: [Monitoring] + security: + - sessionCookie: [] + summary: "Verify the Umami script + website ID resolve (v1.4)" + operationId: umamiTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/auth/refresh: + post: + tags: [Auth] + summary: "Native client: refresh-token rotation (v1.4)" + description: | + Exchanges a refresh token (`hlr_<64hex>`) for a fresh 24h + access token and a new refresh token. Cookie-session browsers + do not use this. Reuse-detection: presenting the same refresh + token a second time revokes every refresh token for the user. + operationId: authRefresh + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [refreshToken] + properties: + refreshToken: { type: string, pattern: "^hlr_[A-Fa-f0-9]{64}$" } + revoke: + type: boolean + default: false + description: "When true, revokes the refresh row + paired access token instead of rotating." + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/auth/refresh/revoke: + post: + tags: [Auth] + summary: "Native client: revoke a refresh token + paired access token (v1.4)" + operationId: authRefreshRevoke + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [refreshToken] + properties: + refreshToken: { type: string, pattern: "^hlr_[A-Fa-f0-9]{64}$" } + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ─── 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 HMAC-SHA-256 hashes server-side + (keyed with `API_TOKEN_HMAC_KEY` so a DB dump alone does not allow + precomputed-rainbow lookups). Bearer tokens never grant admin access + — admin routes require the session cookie. Permission scopes are + enforced from the token's `permissions` array; a token MUST declare + either the explicit required permission for the route or the + wildcard `"*"`. + 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 + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + + 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) ────────────────────────────── + # Used by /api/dashboard/summary (`MetricCard.kind`) and the + # /api/measurements/series adapter. Extended in v1.3.3 to cover the + # body-composition + SpO2 measurements that the server already emits. + # iOS clients SHOULD decode unknown values defensively; we still + # publish the canonical list here so generated codecs stay accurate. + MetricKind: + type: string + enum: + - weight + - bloodPressure + - pulse + - bodyFat + - glucose + - sleep + - steps + - totalBodyWater + - boneMass + - oxygenSaturation + + 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" } diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index f0715aa19..847d89471 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,5468 +1,26 @@ -openapi: 3.1.0 - info: - title: HealthLog API - version: 1.4.0 - description: | - REST API for HealthLog — a personal health-tracking PWA covering weight, - blood pressure, pulse, mood, medication compliance, blood glucose, body - composition (total body water + bone mass), pulse oximetry (SpO2), 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 + url: https://healthlog.dev + description: Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. license: - name: MIT - url: https://github.com/MBombeck/healthlog/blob/main/LICENSE - + name: AGPL-3.0 + url: https://github.com/MBombeck/HealthLog/blob/main/LICENSE + title: HealthLog API + version: 1.4.23 +openapi: 3.1.0 +paths: {} servers: - - url: https://healthlog.bombeck.io - description: Production - - url: http://localhost:3000 - description: Local development - + - description: Production + url: https://healthlog.bombeck.io + - description: Local dev + url: http://localhost:3000 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: Devices - 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" } - - /api/admin/status-overview: - get: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: aggregator for the 6-card status grid (v1.4)" - description: | - Returns the precomputed status payload the admin top strip - renders — users / integrations / monitoring / backups / - maintenance / audit-log summaries with severity already - resolved server-side. Single batched query, no N+1. - operationId: adminStatusOverview - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "403": { $ref: "#/components/responses/Forbidden" } - - /api/admin/backup/test: - post: - tags: [Admin] - security: - - sessionCookie: [] - summary: "Internal: probe the configured S3-compatible backup target (v1.4)" - description: | - Round-trips a small object against the bucket using the - configured `BACKUP_S3_*` credentials. Returns latency and the - sanitised endpoint host on success; never echoes the access key. - operationId: adminBackupTest - 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" } - - # ─── Public + v1.4 additions ───────────────────────────────────────── - - /api/version: - get: - tags: [Public] - summary: "Public: running version + build SHA + license" - description: | - Public, unauthenticated. Returns - `{ version, buildSha, builtAt, license, repository, changelog, docs }`. - Used by the docker container healthcheck and the Settings → - About surface's "Check for updates" button. - operationId: getVersion - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - - /api/integrations/withings/test: - post: - tags: [Withings] - security: - - sessionCookie: [] - summary: "Probe the saved Withings OAuth connection (v1.4)" - operationId: withingsTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/integrations/moodlog/test: - post: - tags: [Moodlog] - security: - - sessionCookie: [] - summary: "Probe moodLog.app reachability (v1.4)" - description: | - SSRF-guarded HEAD against the configured moodLog endpoint. - Returns `{ ok, latency_ms, errorCode? }` so the UI can - localise the failure message. - operationId: moodlogTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/notifications/web-push/test: - post: - tags: [Notifications] - security: - - sessionCookie: [] - summary: "Send a test Web Push to the current user (v1.4)" - operationId: webPushTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/monitoring/glitchtip/test: - post: - tags: [Monitoring] - security: - - sessionCookie: [] - summary: "Probe the Glitchtip ingest endpoint (v1.4)" - operationId: glitchtipTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/monitoring/umami/test: - post: - tags: [Monitoring] - security: - - sessionCookie: [] - summary: "Verify the Umami script + website ID resolve (v1.4)" - operationId: umamiTest - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/auth/refresh: - post: - tags: [Auth] - summary: "Native client: refresh-token rotation (v1.4)" - description: | - Exchanges a refresh token (`hlr_<64hex>`) for a fresh 24h - access token and a new refresh token. Cookie-session browsers - do not use this. Reuse-detection: presenting the same refresh - token a second time revokes every refresh token for the user. - operationId: authRefresh - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [refreshToken] - properties: - refreshToken: { type: string, pattern: "^hlr_[A-Fa-f0-9]{64}$" } - revoke: - type: boolean - default: false - description: "When true, revokes the refresh row + paired access token instead of rotating." - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - "429": { $ref: "#/components/responses/RateLimited" } - - /api/auth/refresh/revoke: - post: - tags: [Auth] - summary: "Native client: revoke a refresh token + paired access token (v1.4)" - operationId: authRefreshRevoke - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [refreshToken] - properties: - refreshToken: { type: string, pattern: "^hlr_[A-Fa-f0-9]{64}$" } - responses: - "200": - { - description: OK, - content: - { - application/json: - { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, - }, - } - "401": { $ref: "#/components/responses/Unauthorized" } - "422": { $ref: "#/components/responses/Unprocessable" } - - # ─── 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 HMAC-SHA-256 hashes server-side - (keyed with `API_TOKEN_HMAC_KEY` so a DB dump alone does not allow - precomputed-rainbow lookups). Bearer tokens never grant admin access - — admin routes require the session cookie. Permission scopes are - enforced from the token's `permissions` array; a token MUST declare - either the explicit required permission for the route or the - wildcard `"*"`. - 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 - - TOTAL_BODY_WATER - - BONE_MASS - - OXYGEN_SATURATION - - 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) ────────────────────────────── - # Used by /api/dashboard/summary (`MetricCard.kind`) and the - # /api/measurements/series adapter. Extended in v1.3.3 to cover the - # body-composition + SpO2 measurements that the server already emits. - # iOS clients SHOULD decode unknown values defensively; we still - # publish the canonical list here so generated codecs stay accurate. - MetricKind: - type: string - enum: - - weight - - bloodPressure - - pulse - - bodyFat - - glucose - - sleep - - steps - - totalBodyWater - - boneMass - - oxygenSaturation - - 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" } diff --git a/package.json b/package.json index fd640072c..333fe04e5 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,9 @@ "test:watch": "vitest", "test:integration": "vitest run --config vitest.integration.config.mts", "e2e": "playwright test", - "e2e:ui": "playwright test --ui" + "e2e:ui": "playwright test --ui", + "openapi:generate": "npx tsx scripts/generate-openapi.ts", + "openapi:check": "npx tsx scripts/check-openapi.ts" }, "dependencies": { "@aws-sdk/client-s3": "^3.1045.0", @@ -70,7 +72,9 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "web-push": "^3.6.7", + "yaml": "^2.8.4", "zod": "^4.4.3", + "zod-openapi": "^5.4.6", "zxcvbn-typescript": "^5.0.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea7dfec66..9043fed28 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,9 +89,15 @@ importers: web-push: specifier: ^3.6.7 version: 3.6.7 + yaml: + specifier: ^2.8.4 + version: 2.8.4 zod: specifier: ^4.4.3 version: 4.4.3 + zod-openapi: + specifier: ^5.4.6 + version: 5.4.6(zod@4.4.3) zxcvbn-typescript: specifier: ^5.0.1 version: 5.0.1 @@ -6193,6 +6199,12 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-openapi@5.4.6: + resolution: {integrity: sha512-P2jsOOBAq/6hCwUsMCjUATZ8szkMsV5VAwZENfyxp2Hc/XPJQpVwAgevWZc65xZauCwWB9LAn7zYeiCJFAEL+A==} + engines: {node: '>=20'} + peerDependencies: + zod: ^3.25.74 || ^4.0.0 + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -12970,6 +12982,10 @@ snapshots: compress-commons: 6.0.2 readable-stream: 4.7.0 + zod-openapi@5.4.6(zod@4.4.3): + dependencies: + zod: 4.4.3 + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/scripts/check-openapi.ts b/scripts/check-openapi.ts new file mode 100644 index 000000000..d5023d1a0 --- /dev/null +++ b/scripts/check-openapi.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env tsx +/** + * Compare the freshly-generated OpenAPI spec against the committed + * `docs/api/openapi.yaml`. Exits non-zero on drift. + * + * Used by the CI gate in `.github/workflows/security.yml`. v1.4.23 runs + * the step with `continue-on-error: true` while the registry catches + * up with the hand-maintained spec; v1.4.24+ flips it to hard-fail. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { stringify } from "yaml"; +import { buildOpenApiDocument } from "../src/lib/openapi/registry"; + +const SPEC_PATH = resolve(__dirname, "..", "docs", "api", "openapi.yaml"); + +function main(): void { + const document = buildOpenApiDocument(); + const generated = stringify(document, { + sortMapEntries: true, + lineWidth: 120, + }); + const committed = readFileSync(SPEC_PATH, "utf8"); + if (generated === committed) { + // eslint-disable-next-line no-console + console.log("OpenAPI spec in sync with source schemas."); + return; + } + // eslint-disable-next-line no-console + console.error( + "OpenAPI spec drift detected. Run `pnpm openapi:generate` and commit the result.", + ); + // Surface a unified-diff-ish hint so PR reviewers see what changed + // without having to re-run the generator locally. + const genLines = generated.split("\n"); + const onDiskLines = committed.split("\n"); + const max = Math.max(genLines.length, onDiskLines.length); + for (let i = 0; i < max; i++) { + if (genLines[i] !== onDiskLines[i]) { + // eslint-disable-next-line no-console + console.error( + `Line ${i + 1}:\n generated: ${JSON.stringify(genLines[i] ?? "")}\n committed: ${JSON.stringify(onDiskLines[i] ?? "")}`, + ); + // Limit the noise — first 5 mismatched lines is enough to point + // the maintainer at the regenerate command. + let shown = 1; + for (let j = i + 1; j < max && shown < 5; j++) { + if (genLines[j] !== onDiskLines[j]) { + // eslint-disable-next-line no-console + console.error( + `Line ${j + 1}:\n generated: ${JSON.stringify(genLines[j] ?? "")}\n committed: ${JSON.stringify(onDiskLines[j] ?? "")}`, + ); + shown++; + } + } + break; + } + } + process.exit(1); +} + +main(); diff --git a/scripts/generate-openapi.ts b/scripts/generate-openapi.ts new file mode 100644 index 000000000..9fd37cb29 --- /dev/null +++ b/scripts/generate-openapi.ts @@ -0,0 +1,35 @@ +#!/usr/bin/env tsx +/** + * Emit `docs/api/openapi.yaml` from the registered Zod schemas + route + * table in `src/lib/openapi/registry.ts`. + * + * v1.4.23 baseline: only the iOS-touched routes are registered (see + * `src/lib/openapi/routes.ts`). The CI step that wraps this script runs + * with `continue-on-error: true` until the registry catches up with the + * rest of the hand-maintained `docs/api/openapi.yaml` — once coverage is + * complete, the gate flips to hard-fail in v1.4.24+. + * + * The output is stable across re-runs: `yaml@2` is configured with + * `sortMapEntries: true` so map keys land in lexicographic order + * regardless of Node version or the order in which schemas were + * imported. + */ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { stringify } from "yaml"; +import { buildOpenApiDocument } from "../src/lib/openapi/registry"; + +const SPEC_PATH = resolve(__dirname, "..", "docs", "api", "openapi.yaml"); + +function main(): void { + const document = buildOpenApiDocument(); + const yaml = stringify(document, { + sortMapEntries: true, + lineWidth: 120, + }); + writeFileSync(SPEC_PATH, yaml, "utf8"); + // eslint-disable-next-line no-console + console.log(`Wrote ${SPEC_PATH} (${yaml.length} bytes)`); +} + +main(); diff --git a/src/lib/openapi/registry.ts b/src/lib/openapi/registry.ts new file mode 100644 index 000000000..2fb0ee2ea --- /dev/null +++ b/src/lib/openapi/registry.ts @@ -0,0 +1,76 @@ +/** + * OpenAPI 3.1 base registry (v1.4.23 / Wave 4 F5a). + * + * Per the W1 stream-3 decision: `zod-openapi` (samchungy) reads Zod v4's + * native `.meta()` metadata, so we annotate schemas in their original + * validation files and register the route table here. The committed + * spec at `docs/api/openapi.yaml` is regenerated via + * `pnpm openapi:generate`; the CI gate in `.github/workflows/security.yml` + * runs `pnpm openapi:check` to detect drift. + * + * The v1.4.23 baseline covers the 6-8 routes the v1.5 iOS app touches + * (auth/login, auth/passkey/login-verify, auth/refresh, measurements + * GET + POST + batch, devices POST, insights/comprehensive). Future + * waves expand coverage organically as routes are touched. Once the + * registry catches the rest of the hand-maintained spec, the CI gate + * flips from `continue-on-error: true` to hard-fail. + * + * The registry is intentionally split: + * - `openApiBase` carries the static document scaffolding (info, + * servers, tags). + * - `buildOpenApiDocument` assembles the full spec by importing the + * registered route table from `./routes`. Routes live in their own + * file so additions don't touch the base. + */ +import { createDocument, type ZodOpenApiObject } from "zod-openapi"; + +export const openApiBase: Pick< + ZodOpenApiObject, + "openapi" | "info" | "servers" | "tags" +> = { + openapi: "3.1.0", + info: { + title: "HealthLog API", + version: "1.4.23", + description: + "Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.", + license: { + name: "AGPL-3.0", + url: "https://github.com/MBombeck/HealthLog/blob/main/LICENSE", + }, + contact: { name: "HealthLog", url: "https://healthlog.dev" }, + }, + servers: [ + { url: "https://healthlog.bombeck.io", description: "Production" }, + { url: "http://localhost:3000", description: "Local dev" }, + ], + tags: [ + { name: "Auth" }, + { name: "Measurements" }, + { name: "Medications" }, + { name: "Mood" }, + { name: "Insights" }, + { name: "Notifications" }, + { name: "Devices" }, + { name: "Admin" }, + ], +}; + +/** + * Assemble the full OpenAPI 3.1 document. Imports the route table + * lazily so the base scaffolding stays usable from contexts that + * shouldn't pull every Zod schema in the project (e.g. a hypothetical + * future devtools-only consumer). + */ +export function buildOpenApiDocument(): ReturnType { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { openApiPaths, openApiComponents } = require("./routes") as { + openApiPaths: ZodOpenApiObject["paths"]; + openApiComponents: ZodOpenApiObject["components"]; + }; + return createDocument({ + ...openApiBase, + paths: openApiPaths, + components: openApiComponents, + }); +} diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts new file mode 100644 index 000000000..de232f0d3 --- /dev/null +++ b/src/lib/openapi/routes.ts @@ -0,0 +1,22 @@ +/** + * OpenAPI route table — populated incrementally per W4 F5b and beyond. + * + * Routes are registered as `paths[][] = ZodOpenApiOperationObject`. + * Schemas exported from `src/lib/validations/*` carry their own + * `.meta({ id, title, description })` annotations so they show up in + * `components.schemas` automatically when referenced via `ref:` or + * direct schema inclusion. + * + * The registry intentionally starts at zero coverage. Commit 2 of the + * v1.4.23 W4 batch wires the iOS-touched routes; later waves expand + * coverage organically. The CI gate at + * `.github/workflows/security.yml` runs in `continue-on-error: true` + * mode while coverage grows. + */ +import type { ZodOpenApiObject } from "zod-openapi"; + +export const openApiPaths: NonNullable = {}; + +export const openApiComponents: NonNullable = { + schemas: {}, +};