Files
HealthLog/docs/api/openapi.yaml
T
Marc-André BombeckandClaude Opus 4.7 98a57eb786 audit(v1.3.3): SpO2 + body-composition + 14 CRITICAL/HIGH security/correctness fixes (#121)
* fix(measurements): close issue #109 + sync body composition across UI/PDF/thresholds

User-visible: TOTAL_BODY_WATER, BONE_MASS, and BLOOD_GLUCOSE now appear in
the measurements filter, badge, mobile icon, and edit dialog (issue #109).
Server-rendered doctor report now includes body composition rows (audit
C-9). Body water + bone mass have proper effective-range thresholds so
severity logic doesn't silently return `nominal` (audit C-15).

Structural: extracted the per-type display maps from measurement-list.tsx
into measurement-list-meta.ts as a deliberate first step toward the
single-source manifest planned for phase P5. Coverage tests now fail-fast
if a future enum addition skips any of: list label/icon/color, doctor-PDF
label/unit/vital-types, chart label, threshold metric order/label/bounds.

Audit-2026-05-07 / phase P0:
- closes #109 (filter UI gap, root cause for the German raw-enum display)
- closes audit C-9 (server PDF used a stale type map)
- closes audit C-15 (no warn/critical bands for body composition)

Tests: 282 → 296 (14 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: align OpenAPI spec + migration comment with body-composition reality

- OpenAPI MeasurementType enum: add TOTAL_BODY_WATER + BONE_MASS (was at
  v1.3.0 baseline; spec drifted past the v1.3 server release)
- Bump openapi version 1.3.0 → 1.3.2 to match package.json
- Migration 0022 comment: TOTAL_BODY_WATER stores kg (water content),
  not percent of body weight. Validators (VALUE_RANGES) and the Withings
  client (type 77 → kg) treat it as kg already; the comment was the lone
  outlier and would mislead the next reviewer.

Audit-2026-05-07 / phase P1a — closes audit M-B1 (partial; OpenAPI enum)
and M-B2 (migration unit comment).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 3 audit CRITICALs (Withings header / idempotency Bearer / GlitchTip URL strip)

C-3 (Withings webhook secret in URL):
Now reads X-Withings-Webhook-Secret header in preference; URL ?secret=…
fallback retained for legacy Withings configurations and emits a Wide
Event warning so operators can spot still-using-the-old-flow integrators.
The query-string secret was leaking into Coolify access logs and any
GlitchTip event whose request.url got captured.

C-4 (idempotency dead for Bearer-token clients):
The default userIdResolver only consulted getSession(). Bearer-authed
clients (the iOS / n8n / headless-API fleet that PR #117 was built for)
fell through, so every retry created duplicate measurements. The default
now tries cookie session first, then Bearer token via
hashToken+apiToken.findUnique with revoked/expiry checks. All three
existing call sites (measurements, mood-entries, medications/intake) get
the fix automatically.

H-B7 (request.url with secrets shipped to GlitchTip):
reportToGlitchtip now strips the query string before forwarding the URL,
so Withings legacy ?secret=… and any OAuth ?code=… that errors during
the callback don't end up in someone else's incident UI.

Audit-2026-05-07 / phase P2.
Tests: 296 → 301 (5 new in idempotency.test for the Bearer fallback +
revoked/expired/null cases).
Deferred to a follow-up (needs DB migration / lock primitive):
  C-2  moodLog webhook → AES-GCM at rest + HMAC-over-body
  C-5  idempotency TOCTOU advisory_lock or in-flight sentinel row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(measurements): add OXYGEN_SATURATION (SpO2) as first-class measurement type — v1.3.3

Closes the OxygenSaturation feature request from #109.

- Migration 0024 extends MeasurementType enum (additive, IF NOT EXISTS)
- Zod validators: plausibility 50–100% (below 50 is incompatible with
  sustained life; above 100 is physically impossible)
- effective-range thresholds: green 95–100, orange 92–94, red <92.
  Sources: BTS Guideline 2017 (target 94–98%), ATS clinical practice
  (≥95% at rest is healthy), AHA/clinical literature (<92% = call
  provider, <88% = ER). Lower-only concern — upper orange wing
  collapses to greenMax since saturation cannot exceed 100%.
- Personalisation: existing thresholds-override UI lets COPD /
  chronic-respiratory users with a doctor-set 88–92 baseline tune the
  bands. METRIC_ORDER + METRIC_LABEL_KEYS extended in
  thresholds-section.tsx; the exhaustive `Record<ThresholdMetric>`
  forces TS to catch any missed surface.
- Withings ScanWatch type 54 mapping (best-effort — only fires for
  pulse-ox-capable devices).
- Wired through measurement-form, measurement-list-meta (icon: Wind),
  health-chart, both doctor-report-pdf renderers (core + browser-side
  near-duplicate), OpenAPI spec (version 1.3.0 → 1.3.3), and i18n in
  4 sections × 2 locales.
- iOS DTO already declared `OXYGEN_SATURATION` — server enum addition
  closes the long-standing drift flagged in audit V2.
- 4 new tests: numeric green band, upper-wing collapse,
  user-override (COPD baseline), METRIC_BOUNDS shape.

Tests: 301 → 305. Typecheck: clean.

Audit-2026-05-07 / v1.3.3 / closes #109 SpO2 feature request.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(analytics): truthfulness pass on medical citations + clamp override orangeMax

Doctor-agent + hallucination-hunter audit V3 found six medical citations
in effective-range.ts that the code did not actually implement. Plus a
latent bug in the user-override path that produced physically-impossible
orangeMax values (>100% saturation, <0% body-fat).

Citation rewrites (no numeric changes):

- ACTIVITY_STEPS: "WHO ≥8000 steps/day" → Saint-Maurice JAMA 2020.
  WHO publishes activity in min/week, not steps. Step-count was a
  misattribution that also leaked into AI prompts.
- TOTAL_BODY_WATER: "ESPEN Body Composition Guidance 2017" → Watson
  formula / ICRP Reference Man. ESPEN 2017 is BIA practice guidance,
  it does not publish kg cut-offs.
- BONE_MASS: "DEXA-equivalent bone mineral content" → "Bioimpedance-
  estimated; not DEXA-comparable". Withings BIA values typically run
  5–7% below DEXA — calling them DEXA-equivalent in a doctor-report
  PDF was a real legal-exposure misattribution.
- OXYGEN_SATURATION: "BTS Guideline 2017 (target 94–98%) + ATS
  clinical practice (≥95% at rest is healthy)" → "consumer pulse-
  oximeter consensus (≥95% normal at rest); slightly tighter than
  BTS 2017 (94–98%); NICE NG115 ≤92% escalation, BMJ panel / FDA
  pulse-oximeter labelling ≤88% ER threshold". Code uses 95-100/92-100
  band — that's an ATS/consumer-consensus number, not BTS's literal
  94-98 target. Soften the citation to match the code, not vice versa.

Bug fix (1 latent, 1 real-today):

- effective-range override path now clamps orangeMin/orangeMax to
  METRIC_BOUNDS.{min,max}. Without the clamp, a COPD user setting
  SpO2 override to {95,100} emitted orangeMax = 100.75 (impossible
  saturation), and a BODY_FAT override of {2,80} emitted orangeMin
  = -9.7 (negative percent). Default-range case 210 already
  hard-coded orangeMax: 100 for the same reason — the override path
  was the only branch missing the invariant.

Tests: 305 → 306 (1 new: clamps override orangeMax to physiological 100%).
Typecheck: clean.

Audit-2026-05-07-V3 / closes Doctor + Senior-Dev medical-citation findings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): Bearer-scope wildcard handling + GDPR Art.17 account-delete erasure

Two CRITICAL fixes from audit V3.

NEW-V3-1: Bearer-scope bypass.
  apiToken.permissions ["*"] is meant to be the wildcard "session-equivalent"
  scope (the iOS app receives this on login). The previous check
    permissions.includes(requiredPermission)
  treated "*" as a literal string — never matched. Today many sensitive
  routes call requireAuth() *without* a requiredPermission, so a leaked iOS
  token can act as a full-scope token (account delete, settings wipe). Once
  those routes adopt requireAuth("scope:name"), this wildcard handling keeps
  the iOS app working while narrower-scoped tokens (e.g. user-issued
  ["medication:ingest"]) get correctly 403'd.

  Same wildcard handling added to the standalone permission checks in
  src/app/api/ingest/medication/route.ts:65, 119.

NEW-V3-2: GDPR Art. 17 account-delete erasure completeness.
  Feedback + AuditLog rows had `onDelete: SetNull` in the schema, which
  preserved PII (free-text symptom descriptions, IP addresses, login-city
  geolocation) after the user was deleted. We now explicitly
  prisma.feedback.deleteMany + prisma.auditLog.deleteMany before the
  cascade-delete, making account deletion genuinely complete erasure.

Tests 306/306 still pass; typecheck clean. Audit V3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* test(pdf): real text-content assertions for body composition + SpO2 rows

Replaces bytes-only "renders body composition rows" theatre test with
pdf-parse-driven text extraction that asserts the actual rendered labels
+ values for TOTAL_BODY_WATER, BONE_MASS, OXYGEN_SATURATION (DE + EN).

Closes V3 audit finding STILL-V2-NEW-3: the previous test only verified
"%PDF-" header + byte-count, so a regression that drops a row would
still pass. Now a missing label fails the test.

- adds dev dep: pdf-parse@^2.4.5
- 308/308 tests pass (was 306, +2 SpO2 + EN-locale coverage)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(measurements): close enum drift cousins — derive 5 type-arrays from canonical enum + extend 2 contract enums

V3 audit finding "enum drift cousins": 7 hardcoded module-level type arrays
silently dropped SpO2/TBW/BoneMass/BloodGlucose from their respective
domains. Adding a new measurement type required hunting these down.

Now all aggregation paths derive from `measurementTypeEnum.options`:
- /api/insights/comprehensive (AI insights)
- /api/dashboard/summary (iOS dashboard adapter)
- /api/analytics (analytics aggregator)
- /lib/insights/general-status (AI general-status fetch)
- /api/import (Zod schema — round-trip now covers all 11 types)

External-contract enums extended additively (no breakage for old clients):
- /api/measurements/series kindEnum: + oxygen, totalBodyWater, boneMass
- /api/dashboard/widgets widgetIdEnum: + oxygenSaturation
- DashboardWidgetId + DEFAULT_DASHBOARD_LAYOUT: + oxygenSaturation row
- formatMeasurementsForExport: glucoseContext now round-trips

Coverage test (`measurement-type-enum-coverage.test.ts`) asserts the
canonical enum stays the source of truth and documents PDF exclusions
(BLOOD_GLUCOSE renders via context section, SLEEP/STEPS lifestyle-only).

313/313 tests pass (was 308, +5 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): close 6 V3-audit HIGH findings (CSP, web-push SSRF, IP geo, AI provider leak, import rate-limit)

- CSP: chatgpt.com + api.openai.com only on /settings/ai/** routes (was
  blanket connect-src on every page including /auth/login → DOM-XSS exfil
  channel)
- web-push: webPushSubscriptionSchema.endpoint requires HTTPS + isPublicUrl
  refinement; was z.url() only → blind SSRF probe via Push subscription
- isPublicUrl: also fixes a regression where DNS labels starting with
  "fc"/"fd" (fcm.googleapis.com etc.) were falsely classified as IPv6
  unique-local. Check is now gated on a colon being present in the host.
- /api/ai/test: stop returning provider err.message + bodyExcerpt to the
  client (leaked provider URLs / partial keys / internal headers). Now
  returns categorised generic message; full details land in the wide-event
  via annotate() for the operator.
- /lib/geo (auditLog IP geo lookup): switch default provider to
  ipwho.is over HTTPS, accept ipwho.is + ip-api pro response shapes,
  add IP_GEO_LOOKUP_DISABLED env, refuse non-HTTPS configured URLs.
  Previously leaked IP+timestamp over plaintext HTTP every auth event
  (GDPR Art. 32 + Art. 44).
- /api/import: add 5/hour rate-limit (was unlimited; bulk-injection vector)

Tests: 325/325 pass (was 313, +12 new — webPushSubscriptionSchema SSRF
guards, fc/fd-prefix domain regression, geo HTTPS-only, geo provider
shapes, geo opt-out env).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security,gdpr): trusted-proxy IP, audit-log retention, idempotency cachable filter, Bearer mock tightening

V3 audit follow-on bundle covering 4 HIGH findings.

(I) getClientIp — TRUST_PROXY_HOPS env (default 1) replaces blind XFF
trust. Reads chain right-to-left so client-supplied leftmost entries
cannot rotate the per-IP rate-limit bucket. Default 1 matches typical
single-proxy self-host (Coolify/Caddy/Cloudflare-Tunnel); 0 forces
x-real-ip / null. New 7-test suite covers the rotation attack +
malformed entries + zero-hop fallback.

(F) Audit-log retention job — daily 03:15 cleanup of audit_logs older
than AUDIT_LOG_RETENTION_DAYS (default 365). Closes GDPR Art. 5(1)(e)
"storage limitation" gap; previously IPs + city + login events
accumulated forever. 6-test suite covers default + override +
misconfig guard (rejects <7 days).

(G) isCachableStatus — extracted the inline 4xx/5xx filter into a
named, exported, tested function. 7 cases cover the full do-not-cache
contract (401 / 403 / 408 / 429 / any 5xx) and confirm 2xx + 4xx
validation are still cached.

(H) Bearer-mock tightening — apiToken.findUnique mocks in
require-auth-bearer.test + idempotency.test now assert the
where.tokenHash shape uses the hashed value (not the raw bearer).
A regression that switched back to raw-token comparison would have
been silent without this assertion.

Tests: 345/345 pass (was 332, +13 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security): encrypt moodLog webhook secret at rest with AES-256-GCM (V3 audit STILL-V2-C-2)

The mood_log_webhook_secret column previously stored a plaintext secret
that the webhook handler compared timing-safe against every enabled
user. A DB dump or backup snapshot leaked active webhook credentials.

New contract — no schema migration required:
- Writes go through encryptMoodLogSecret() (AES-256-GCM via existing
  crypto helper, ENCRYPTION_KEY).
- Reads go through readMoodLogSecret() which transparently decrypts
  envelopes and tolerates legacy plaintext rows during the transition
  (graceful upgrade path — no breaking change for self-hosted users).
- Webhook lookup decrypts each candidate before timing-safe compare.
- Status endpoint decrypts for the user's settings page.

One-shot startup migration in reminder-worker rotates any leftover
plaintext rows on next worker boot. Idempotent; encrypted rows are
skipped.

Tests: 350/350 pass (was 345, +5 new — round-trip, legacy passthrough,
isLegacyPlaintext semantics, rotateLegacy idempotence).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: sync README + AGENTS + CLAUDE + CHANGELOG + OpenAPI for v1.3.3 (V3 audit doc-drift)

V3 audit Doku Specialist findings — drift between code and meta-docs.

- README §Key Features now mentions pulse oximetry (SpO₂) + Withings
  ScanWatch sync; Tech Stack model count corrected 23 → 25.
- AGENTS.md status promoted v1.3.2 → v1.3.3; vitest config corrected
  `vitest.config.ts` → `vitest.config.mts`; migration range corrected
  `0001–0022` → `0001–0024`; model count + list updated to 25 (adds
  Device, IdempotencyKey).
- CLAUDE.md model count updated (2 occurrences).
- CHANGELOG.md v1.3.3 expanded with Security + Internal sections that
  were missing the audit-fix-marathon work (Bearer-scope, account-delete
  cascade, moodLog encryption, CSP tightening, Web-Push SSRF, IP-geo
  HTTPS, AI-test leak fix, import rate-limit, trusted-proxy XFF,
  audit-log retention, idempotency cachable filter, Bearer mock
  tightening, enum-drift cousins, PDF text-content tests).
- OpenAPI Bearer description corrected `SHA-256 hashes` →
  `HMAC-SHA-256 hashes (keyed with API_TOKEN_HMAC_KEY)` so the docs
  match the actual server-side implementation. Wildcard scope contract
  documented.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audit-review): close 4 HIGH + 2 MEDIUM findings from multi-agent review pass

Multi-agent review of the audit-fix branch surfaced four HIGH issues
and a couple of MEDIUM hardenings. All addressed before merge.

HIGH-1 — OpenAPI MetricKind enum drift (iOS strict-decode crash risk).
The dashboard-summary route now emits totalBodyWater/boneMass/
oxygenSaturation MetricCards, but the spec still listed only the
original 7 kinds. Generated iOS Codable would have crashed the dash
the moment a user logged SpO2. MetricKind extended with the 3 new
kinds + comment documenting unknown-value defensive decoding.

HIGH-2 — kindEnum naming consistency on /api/measurements/series.
The series adapter shipped "oxygen" while the dashboard summary used
"oxygenSaturation"; both routes now use "oxygenSaturation" so iOS
decoders can share a single MetricKind type across endpoints.

HIGH-3 — /api/ai/test no-leak guard had no test. Added 4 tests that
mock provider errors with sk-leaked-key + provider URLs in the err
fields and assert the response body never echoes them, plus the
3-status-categorised generic messages (401/429/5xx/other).

HIGH-4 — /api/import 5/hour rate-limit had no test. Added 2 tests that
mock checkRateLimit allowed/disallowed and assert the 429 path uses
the right key shape (`import:<userId>`, 5, 1h) and that the within-
quota path proceeds.

MEDIUM-3 — getClientIp under-length XFF chain regression. With
TRUST_PROXY_HOPS=2 and a chain of length 1, the previous clamp
returned the leftmost (attacker-controlled) entry, silently
re-introducing the rotation attack the helper was meant to close.
Now: when chain is shorter than configured hops, refuse XFF entirely
(fall back to x-real-ip / null). Added regression test.

LOW-1 — TRUST_PROXY_HOPS=garbage now throws at boot instead of
silently degrading to hops=0. Added regression test.

Tests: 358/358 pass (was 350, +8 new). Typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 01:05:56 +02:00

4976 lines
165 KiB
YAML

openapi: 3.1.0
info:
title: HealthLog API
version: 1.3.3
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_<hex>' }
name: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/tokens/{id}:
parameters:
- $ref: '#/components/parameters/IdPathParam'
delete:
tags: [Tokens]
summary: Revoke an API token
operationId: revokeToken
responses:
'200':
description: Revoked
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
revoked: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
# ─── Ingest (external) ────────────────────────────────────────
/api/ingest/medication:
post:
tags: [Ingest]
summary: External medication intake ingest (Bearer token, idempotent)
operationId: ingestMedicationIntake
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ExternalIntakeInput' }
responses:
'200':
description: Existing event returned (idempotent replay)
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/MedicationIntakeEvent' }
'201':
description: Created
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/MedicationIntakeEvent' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/Unprocessable' }
'429': { $ref: '#/components/responses/RateLimited' }
# ─── Notifications ────────────────────────────────────────────
/api/notifications/preferences:
get:
tags: [Notifications]
summary: List notification channels + preferences
operationId: getNotificationPreferences
responses:
'200':
description: Preferences
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/NotificationPreferencesResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
put:
tags: [Notifications]
summary: Toggle a single channel/event preference
operationId: updateNotificationPreference
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/UpdateNotificationPreferenceInput' }
responses:
'200':
description: Updated
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/NotificationPreference' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/notifications/vapid:
get:
tags: [Notifications]
summary: Get VAPID public key (Web Push)
operationId: getVapidKey
security: []
responses:
'200':
description: VAPID key
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
publicKey: { type: string }
'503':
description: Not configured
content:
application/json:
schema: { $ref: '#/components/schemas/ApiError' }
/api/notifications/web-push:
post:
tags: [Notifications]
summary: Subscribe a Web Push endpoint
operationId: subscribeWebPush
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/WebPushSubscribeInput' }
responses:
'200':
description: Subscribed
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
subscribed: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
delete:
tags: [Notifications]
summary: Unsubscribe a Web Push endpoint
operationId: unsubscribeWebPush
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
endpoint: { type: string, format: uri }
required: [endpoint]
responses:
'200':
description: Unsubscribed
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
unsubscribed: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
# ─── User configuration ───────────────────────────────────────
/api/user/ai-provider:
get:
tags: [User]
summary: Get user-level AI provider config
operationId: getUserAiProvider
responses:
'200':
description: Config
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/UserAiProvider' }
'401': { $ref: '#/components/responses/Unauthorized' }
patch:
tags: [User]
summary: Update user-level AI provider config
operationId: updateUserAiProvider
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/UpdateUserAiProviderInput' }
responses:
'200':
description: Updated
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
updated: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/user/thresholds:
get:
tags: [User]
summary: Get effective + override threshold ranges
operationId: getThresholds
responses:
'200':
description: Thresholds
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/ThresholdsResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
put:
tags: [User]
summary: Set per-metric threshold overrides (partial)
operationId: updateThresholds
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ThresholdOverrides' }
responses:
'200':
description: Updated
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
overrides: { $ref: '#/components/schemas/ThresholdOverrides' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
delete:
tags: [User]
summary: Reset threshold overrides (all or one metric)
operationId: resetThresholds
parameters:
- in: query
name: metric
schema: { type: string }
description: 'If supplied, only this metric is reset; otherwise all overrides are cleared.'
responses:
'200':
description: Reset
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
overrides: { $ref: '#/components/schemas/ThresholdOverrides' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
# ─── Analytics + AI test ──────────────────────────────────────
/api/analytics:
get:
tags: [Analytics]
summary: Per-type measurement summaries + BMI + BP-in-target
operationId: getAnalytics
responses:
'200':
description: Analytics
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/AnalyticsResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/ai/test:
post:
tags: [User]
summary: Test the configured AI provider
operationId: testAiProvider
responses:
'200':
description: AI test result
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/AiTestResult' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
'429': { $ref: '#/components/responses/RateLimited' }
'502':
description: Upstream AI provider failure
content:
application/json:
schema: { $ref: '#/components/schemas/ApiError' }
# ─── Export / Import ──────────────────────────────────────────
/api/export:
get:
tags: [Export]
summary: Export user data (CSV or JSON)
operationId: exportData
parameters:
- in: query
name: format
schema: { type: string, enum: [csv, json], default: json }
- in: query
name: type
schema:
type: string
enum: [measurements, medications, intake, mood, all]
default: all
responses:
'200':
description: Export payload
content:
application/json:
schema:
type: object
properties:
data:
type: object
additionalProperties: true
text/csv:
schema: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
'429': { $ref: '#/components/responses/RateLimited' }
/api/import:
post:
tags: [Export]
summary: Import previously exported data (JSON)
operationId: importData
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/ImportInput' }
responses:
'200':
description: Import stats
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/ImportResult' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
# ─── Audit log + Feedback + Bug report ────────────────────────
/api/audit-log:
get:
tags: [AuditLog]
summary: Personal audit log entries
operationId: getAuditLog
parameters:
- $ref: '#/components/parameters/LimitParam'
- $ref: '#/components/parameters/OffsetParam'
responses:
'200':
description: Entries
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/AuditLogResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/feedback:
get:
tags: [Feedback]
summary: List the user's submitted feedback
operationId: listFeedback
parameters:
- $ref: '#/components/parameters/LimitParam'
- $ref: '#/components/parameters/OffsetParam'
responses:
'200':
description: Feedback
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
items:
type: array
items: { $ref: '#/components/schemas/FeedbackSummary' }
meta: { $ref: '#/components/schemas/PageMeta' }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
tags: [Feedback]
summary: Submit feedback (with optional screenshot)
operationId: submitFeedback
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/CreateFeedbackInput' }
responses:
'201':
description: Created
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/FeedbackSummary' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
'429': { $ref: '#/components/responses/RateLimited' }
/api/bugreport:
post:
tags: [BugReport]
summary: File a bug report (creates a GitHub issue when configured)
operationId: submitBugReport
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/BugReportInput' }
responses:
'200':
description: Issue created
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
issueNumber: { type: integer }
issueUrl: { type: string, format: uri }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
'429': { $ref: '#/components/responses/RateLimited' }
'500': { $ref: '#/components/responses/ServerError' }
'503':
description: Bug report not configured
content:
application/json:
schema: { $ref: '#/components/schemas/ApiError' }
/api/bugreport/status:
get:
tags: [BugReport]
summary: Whether bug-report submission is configured
operationId: getBugReportStatus
responses:
'200':
description: Status
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
configured: { type: boolean }
isAdmin: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { $ref: '#/components/responses/RateLimited' }
# ─── Settings (per-user) ──────────────────────────────────────
/api/settings/account:
delete:
tags: [Settings]
summary: Delete the user's account (and all data)
operationId: deleteAccount
responses:
'200':
description: Account deleted
content:
application/json:
schema: { $ref: '#/components/schemas/DeletedEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/settings/data:
delete:
tags: [Settings]
summary: Wipe the user's data (keep account)
operationId: wipeData
responses:
'200':
description: Data wiped
content:
application/json:
schema: { $ref: '#/components/schemas/DeletedEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/settings/global-services:
get:
tags: [Settings]
summary: Global service availability flags (telegram, ntfy, web-push, …)
operationId: getGlobalServices
responses:
'200':
description: Flags
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/GlobalServicesFlags' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/settings/moodlog:
put:
tags: [Settings]
summary: Configure moodLog integration
operationId: updateMoodLogSettings
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/MoodLogSettingsInput' }
responses:
'200':
description: Updated
content:
application/json:
schema: { $ref: '#/components/schemas/UpdatedEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
delete:
tags: [Settings]
summary: Disconnect moodLog integration
operationId: deleteMoodLogSettings
responses:
'200':
description: Removed
content:
application/json:
schema: { $ref: '#/components/schemas/DeletedEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/settings/ntfy:
get:
tags: [Settings]
summary: Get ntfy channel settings
operationId: getNtfySettings
responses:
'200':
description: Config
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/NtfySettings' }
'401': { $ref: '#/components/responses/Unauthorized' }
put:
tags: [Settings]
summary: Update ntfy channel settings
operationId: updateNtfySettings
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/NtfySettingsInput' }
responses:
'200':
description: Updated
content:
application/json:
schema: { $ref: '#/components/schemas/UpdatedEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/settings/ntfy/test:
post:
tags: [Settings]
summary: Send a test ntfy notification
operationId: testNtfy
responses:
'200':
description: Test result
content:
application/json:
schema: { $ref: '#/components/schemas/ApiEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/settings/reminder-thresholds:
get:
tags: [Settings]
summary: Get reminder lateness thresholds (admin-defined defaults)
operationId: getReminderThresholds
responses:
'200':
description: Thresholds
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/ReminderThresholds' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/settings/telegram:
get:
tags: [Settings]
summary: Get Telegram channel settings
operationId: getTelegramSettings
responses:
'200':
description: Config
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/TelegramSettings' }
'401': { $ref: '#/components/responses/Unauthorized' }
put:
tags: [Settings]
summary: Update Telegram channel settings
operationId: updateTelegramSettings
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/TelegramSettingsInput' }
responses:
'200':
description: Updated
content:
application/json:
schema: { $ref: '#/components/schemas/UpdatedEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/settings/telegram/test:
post:
tags: [Settings]
summary: Send a test Telegram message
operationId: testTelegram
responses:
'200':
description: Test result
content:
application/json:
schema: { $ref: '#/components/schemas/ApiEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
# ─── Withings integration ────────────────────────────────────
/api/withings/connect:
get:
tags: [Integrations]
summary: Begin Withings OAuth (302 redirect)
operationId: withingsConnect
responses:
'302':
description: Redirect to Withings
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/withings/callback:
get:
tags: [Integrations]
summary: Withings OAuth callback (302 to /settings)
operationId: withingsCallback
parameters:
- in: query
name: code
schema: { type: string }
- in: query
name: state
schema: { type: string }
responses:
'302':
description: Redirect back to /settings with status
/api/withings/disconnect:
post:
tags: [Integrations]
summary: Disconnect Withings
operationId: withingsDisconnect
responses:
'200':
description: Disconnected
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
disconnected: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/api/withings/credentials:
get:
tags: [Integrations]
summary: Whether Withings client credentials are stored
operationId: getWithingsCredentialsStatus
responses:
'200':
description: Status
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
hasCredentials: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
put:
tags: [Integrations]
summary: Save Withings client ID + secret (encrypted)
operationId: saveWithingsCredentials
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/WithingsCredentialsInput' }
responses:
'200':
description: Saved
content:
application/json:
schema: { $ref: '#/components/schemas/UpdatedEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
delete:
tags: [Integrations]
summary: Delete Withings credentials (and disconnect)
operationId: deleteWithingsCredentials
responses:
'200':
description: Deleted
content:
application/json:
schema: { $ref: '#/components/schemas/DeletedEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/withings/status:
get:
tags: [Integrations]
summary: Withings connection status
operationId: withingsStatus
responses:
'200':
description: Status
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/WithingsStatus' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/withings/sync:
post:
tags: [Integrations]
summary: Trigger a Withings sync (incremental or full)
operationId: withingsSync
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
fullSync: { type: boolean, default: false }
responses:
'200':
description: Sync result
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
imported: { type: integer }
fullSync: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/withings/webhook:
post:
tags: [Webhooks]
summary: Withings webhook (notification of new measurements)
operationId: withingsWebhook
security: []
parameters:
- in: query
name: secret
schema: { type: string }
description: 'Shared secret for webhook authentication.'
responses:
'200':
description: Acknowledged
content:
application/json:
schema:
type: object
properties:
status: { type: string }
'401':
description: Unauthorized
'429':
description: Rate limited
get:
tags: [Webhooks]
summary: Withings webhook URL verification
operationId: withingsWebhookVerifyGet
security: []
parameters:
- in: query
name: secret
schema: { type: string }
responses:
'200':
description: OK
content:
application/json:
schema:
type: object
properties:
status: { type: string }
'401':
description: Unauthorized
head:
tags: [Webhooks]
summary: Withings webhook URL verification (HEAD)
operationId: withingsWebhookVerifyHead
security: []
parameters:
- in: query
name: secret
schema: { type: string }
responses:
'200':
description: OK
'401':
description: Unauthorized
# ─── moodLog integration ─────────────────────────────────────
/api/integrations/moodlog/status:
get:
tags: [Integrations]
summary: moodLog connection status
operationId: moodLogStatus
responses:
'200':
description: Status
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/MoodLogStatus' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/integrations/moodlog/sync:
post:
tags: [Integrations]
summary: Trigger a moodLog sync
operationId: moodLogSync
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
fullSync: { type: boolean, default: false }
responses:
'200':
description: Sync result
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
imported: { type: integer }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/integrations/moodlog/webhook:
post:
tags: [Webhooks]
summary: Inbound webhook from moodLog
operationId: moodLogWebhook
security: []
parameters:
- in: header
name: X-Webhook-Secret
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'200':
description: Acknowledged
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'429': { $ref: '#/components/responses/RateLimited' }
/api/telegram/webhook:
post:
tags: [Webhooks]
summary: Telegram inbound webhook
operationId: telegramWebhook
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'200':
description: Acknowledged
get:
tags: [Webhooks]
summary: Telegram webhook health probe
operationId: telegramWebhookGet
security: []
responses:
'200':
description: OK
# ─── Public monitoring / proxies ─────────────────────────────
/api/monitoring/csp-report:
post:
tags: [Monitoring]
summary: Browser CSP violation report sink
operationId: cspReport
security: []
requestBody:
required: true
content:
application/csp-report:
schema:
type: object
additionalProperties: true
application/json:
schema:
type: object
additionalProperties: true
responses:
'204':
description: Accepted (no body)
/api/monitoring/glitchtip:
post:
tags: [Monitoring]
summary: Forward errors to Glitchtip
operationId: glitchtipReport
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'204':
description: Accepted
/api/monitoring/settings:
get:
tags: [Monitoring]
summary: Public monitoring settings (umami / glitchtip flags)
operationId: getMonitoringSettings
security: []
responses:
'200':
description: Public settings
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/PublicMonitoringSettings' }
/api/monitoring/umami-script:
get:
tags: [Monitoring]
summary: Server-side proxy for the Umami tracker script
operationId: getUmamiScript
security: []
responses:
'200':
description: Tracker script
content:
application/javascript:
schema: { type: string }
/api/send:
post:
tags: [Monitoring]
summary: Server-side proxy for Umami /api/send
operationId: umamiProxySend
security: []
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
'200':
description: Forwarded
'204':
description: No-op when umami is disabled
'413':
description: Payload too large
# ─── Admin (internal) ────────────────────────────────────────
/api/admin/ai-settings:
get:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: get admin-managed AI fallback settings'
operationId: adminGetAiSettings
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
put:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: update admin-managed AI fallback settings'
operationId: adminUpdateAiSettings
requestBody:
required: true
content:
application/json:
schema: { type: object, additionalProperties: true }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/admin/audit-log:
get:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: cross-user audit log'
operationId: adminGetAuditLog
parameters:
- $ref: '#/components/parameters/LimitParam'
- $ref: '#/components/parameters/OffsetParam'
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/data:
delete:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: wipe per-user or all data'
operationId: adminWipeData
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/feedback:
get:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: list all feedback'
operationId: adminListFeedback
parameters:
- $ref: '#/components/parameters/LimitParam'
- $ref: '#/components/parameters/OffsetParam'
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/feedback/{id}:
parameters:
- $ref: '#/components/parameters/IdPathParam'
patch:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: update feedback status / admin note'
operationId: adminUpdateFeedback
requestBody:
required: true
content:
application/json:
schema: { type: object, additionalProperties: true }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: delete feedback'
operationId: adminDeleteFeedback
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/api/admin/feedback/{id}/github:
parameters:
- $ref: '#/components/parameters/IdPathParam'
post:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: escalate feedback to a GitHub issue'
operationId: adminEscalateFeedback
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
/api/admin/monitoring/glitchtip-test:
post:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: send Glitchtip test event'
operationId: adminGlitchtipTest
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/monitoring/umami-test:
post:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: send Umami test ping'
operationId: adminUmamiTest
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/notifications/reminder-check:
post:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: trigger medication reminder evaluation'
operationId: adminTriggerReminderCheck
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/notifications/test:
post:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: send a test notification'
operationId: adminTestNotification
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/settings:
get:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: get app-wide settings'
operationId: adminGetSettings
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
put:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: update app-wide settings'
operationId: adminUpdateSettings
requestBody:
required: true
content:
application/json:
schema: { type: object, additionalProperties: true }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/admin/status:
get:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: deep system status'
operationId: adminStatus
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/tokens:
get:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: list all API tokens (cross-user)'
operationId: adminListTokens
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/users:
get:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: list all users'
operationId: adminListUsers
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
/api/admin/users/{id}:
parameters:
- $ref: '#/components/parameters/IdPathParam'
put:
tags: [Admin]
security:
- sessionCookie: []
summary: 'Internal: update a user (username/email/role)'
operationId: adminUpdateUser
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/AdminUpdateUserInput' }
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/admin/users/{id}/reset-password:
parameters:
- $ref: '#/components/parameters/IdPathParam'
post:
tags: [Admin]
security:
- sessionCookie: []
summary: "Internal: reset a user's password"
operationId: adminResetUserPassword
responses:
'200': { description: OK, content: { application/json: { schema: { $ref: '#/components/schemas/ApiEnvelope' } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/Forbidden' }
'404': { $ref: '#/components/responses/NotFound' }
# ─── iOS adapter endpoints (v1.3) ────────────────────────────────────
/api/dashboard/summary:
get:
tags: [Dashboard]
summary: 'iOS: aggregated dashboard summary'
description: |
Single-call aggregate for the iOS DashboardSummary view. Combines
greeting, current/longest activity-day streak, today's medication
compliance, optional highlight insight, and per-metric latest +
7-day sparkline + trend.
operationId: getDashboardSummary
responses:
'200':
description: Dashboard summary
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/DashboardSummary' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/measurements/series:
get:
tags: [Measurements]
summary: 'iOS: time-series + summary stats for a metric kind'
operationId: getMeasurementSeries
parameters:
- in: query
name: kind
required: true
schema: { $ref: '#/components/schemas/MetricKind' }
- in: query
name: days
schema:
type: integer
minimum: 1
maximum: 365
default: 30
responses:
'200':
description: Series payload
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/MeasurementSeries' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/integrations/healthkit:
get:
tags: [Integrations]
summary: 'iOS: HealthKit per-metric direction config'
operationId: getHealthKitConfig
responses:
'200':
description: HealthKit config
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/HealthKitConfig' }
'401': { $ref: '#/components/responses/Unauthorized' }
patch:
tags: [Integrations]
summary: 'iOS: update HealthKit per-metric direction config'
operationId: updateHealthKitConfig
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/HealthKitConfigPatch' }
responses:
'200':
description: Updated config
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/HealthKitConfig' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/user/profile:
get:
tags: [Auth]
summary: 'iOS: flattened user profile'
operationId: getUserProfile
responses:
'200':
description: User profile
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/IosUserProfile' }
'401': { $ref: '#/components/responses/Unauthorized' }
patch:
tags: [Auth]
summary: 'iOS: update profile fields'
operationId: updateUserProfile
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/IosUserProfilePatch' }
responses:
'200':
description: Updated profile
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data: { $ref: '#/components/schemas/IosUserProfile' }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { $ref: '#/components/responses/Conflict' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/devices:
post:
tags: [Notifications]
summary: 'iOS: register an APNs device token'
operationId: registerDevice
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/DeviceRegisterInput' }
responses:
'201':
description: Device registered (or token transferred)
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: object
properties:
id: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/medications/intake:
get:
tags: [Medications]
summary: 'iOS: today list or compliance buckets'
operationId: listIntakeAggregator
parameters:
- in: query
name: scope
required: true
schema: { type: string, enum: [today, compliance] }
- in: query
name: days
schema:
type: integer
minimum: 1
maximum: 365
default: 30
responses:
'200':
description: Aggregator payload
content:
application/json:
schema: { $ref: '#/components/schemas/ApiEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { $ref: '#/components/responses/Unprocessable' }
post:
tags: [Medications]
summary: 'iOS: update an intake event by id (taken/skipped/snoozed)'
operationId: updateIntakeAggregator
requestBody:
required: true
content:
application/json:
schema: { $ref: '#/components/schemas/IntakeUpdateInput' }
responses:
'200':
description: Updated intake event
content:
application/json:
schema: { $ref: '#/components/schemas/ApiEnvelope' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'422': { $ref: '#/components/responses/Unprocessable' }
/api/insights/cards:
get:
tags: [Insights]
summary: 'iOS: rule-based insight cards'
operationId: getInsightCards
responses:
'200':
description: Insight cards
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/InsightCard' }
'401': { $ref: '#/components/responses/Unauthorized' }
/api/insights/correlations:
get:
tags: [Insights]
summary: 'iOS: cross-metric correlations (placeholder)'
operationId: getInsightCorrelations
description: |
Returns an empty array until the correlations engine is wired up
(Phase 7). Audited via `insights.correlations.empty`.
responses:
'200':
description: Correlations
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/ApiEnvelope'
- type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/InsightCorrelation' }
'401': { $ref: '#/components/responses/Unauthorized' }
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: 'hlk_<hex>'
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 <token>`.
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' }