mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
* 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>
4976 lines
165 KiB
YAML
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' }
|