From 2ef76bfe75d8e59b077e34ab562753f5803654a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 8 May 2026 16:15:03 +0200 Subject: [PATCH] docs: sync README, AGENTS, CLAUDE, OpenAPI, migration guide for v1.4 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v1.4 marathon and the v1.4.1 cycle that followed (per-section admin extraction, Postgres testcontainers integration suite, Playwright + axe-core E2E foundation) landed several user- and operator-facing changes that hadn't been backfilled into the docs shipped inside the repo. This commit closes that drift. README.md - Adds Multi-tenant ready and Test connection buttons feature bullets. - Tech-stack model count 25 → 26 (RefreshToken added in v1.4.0). - API reference: new "Public + v1.4 additions" section listing /api/version, the five Test connection endpoints, the two refresh endpoints, and the two new admin endpoints (status-overview, backup/test). AGENTS.md - Status block rewritten to reflect what shipped in 1.4.0 vs what's in flight for 1.4.1. - File layout: settings monolith replaced by /settings/[section] routing, admin/page.tsx now the 77-LOC shell, src/components/ settings/ and src/components/admin/ added. - Removed the stale "Settings Page" gotcha (about non-blocking set-state-in-effect lint); the rule is strict now. - Database Models 25 → 26. CLAUDE.md - Same model-count and component-folder updates as AGENTS.md. - Adds Multi-tenant prep and Native API client patterns under Important Patterns (HEALTHLOG_PROCESS_TYPE, ENCRYPTION_KEYS, BACKUP_S3_*, refresh-token rotation). - Lists tests/integration/ and e2e/ in File Layout. docs/api/openapi.yaml - Adds 11 new endpoints: GET /api/version, the five test endpoints (Withings, moodLog, Web Push, Glitchtip, Umami), the two refresh endpoints, GET /api/admin/status-overview, POST /api/admin/backup/test. - The bulk of the diff is prettier reformatting that the existing file had drifted from (single → double quotes throughout YAML); no semantic change. docs/migration/v1.3-to-v1.4.md - Corrects the now-wrong claim that v1.4 ships no migrations (it ships 0025_refresh_tokens + 0025_user_locale_drift_fix). - Adds full env-var sections for the Worker/Web split, Encryption key versioning, and Off-host backup target. - Documents URL changes (/settings#anchor → /settings/[section]). - Replaces the "deferred to a future release" list with a landed-in-v1.4 vs landed-in-v1.4.1 status table. Co-Authored-By: Marc-André Bombeck --- AGENTS.md | 45 +- CLAUDE.md | 10 +- README.md | 130 +- docs/api/openapi.yaml | 2497 +++++++++++++++++++------------- docs/migration/v1.3-to-v1.4.md | 150 +- 5 files changed, 1723 insertions(+), 1109 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d67f443..50deb52 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,16 +6,19 @@ Instructions for AI coding agents (OpenAI Codex, Claude Code, Cursor, etc.) work **HealthLog** — a personal health-tracking web app (weight, blood pressure, pulse, mood, medication compliance) with Withings integration, moodLog.app sync, Dracula-themed UI, mobile-first PWA design. -**Status**: v1.4.0 — UI guidelines + reusable Skeleton/EmptyState -primitives, medical citations consolidated under -`src/lib/medical-citations.ts` (BP_DIA hypotension floor, ESH 2023 -alignment, "WHO 8000 steps" hallucination removed, ACE body-fat bands -corrected), localised medication reminders + dashboard greeting + -Zod validations, two more N+1 queries closed, Berlin-TZ-aware weekly -buckets, single-row dashboard tile strip, public `/api/version` -endpoint, AI provider connection-test honours unsaved selection, -health-data inputs block password-manager autofill by default. See -GitHub Releases + CHANGELOG.md for the full feature timeline. +**Status**: v1.4.0 (released) → v1.4.1 in progress. v1.4.0 shipped +the UI guidelines + Skeleton/EmptyState primitives, medical-citations +consolidation, the per-route `/settings/[section]` split, the +status-first admin grid + aggregator endpoint, five new "Test +connection" buttons, AI insights with inline charts via allowlisted +metric tokens, off-host encrypted S3 backup, encryption-key +versioning + rotation CLI, optional `HEALTHLOG_PROCESS_TYPE` worker / +web split, and short-lived 24h access tokens with refresh-token +rotation for native API clients. v1.4.1 follows up with per-section +admin component extraction (the inner monolith into one file per +panel), a Postgres testcontainers integration test suite, and a +Playwright + axe-core E2E foundation. See GitHub Releases + +CHANGELOG.md for the full feature timeline. ## Tech Stack @@ -75,7 +78,7 @@ src/ │ ├── layout.tsx # Root layout (viewport-fit: cover for PWA) │ ├── page.tsx # Dashboard (/) with quick entry dropdown │ ├── globals.css # Dracula theme CSS variables -│ ├── admin/page.tsx # Admin panel +│ ├── admin/page.tsx # Admin shell — 77 LOC, mounts the per-section components from src/components/admin/ │ ├── auth/login/page.tsx # Login │ ├── auth/register/page.tsx # Registration │ ├── achievements/page.tsx # Gamification achievements @@ -85,7 +88,8 @@ src/ │ ├── medications/page.tsx # Medications management │ ├── notifications/page.tsx # Notification preferences matrix │ ├── onboarding/page.tsx # 4-step guided onboarding -│ ├── settings/page.tsx # All settings (8 top-level sections, ~3150 lines — split tracked for 1.4.0) +│ ├── settings/page.tsx # 308-redirects /settings → /settings/account +│ ├── settings/[section]/page.tsx # Per-section route shell (account, integrations, notifications, dashboard, ai, api, advanced, about) │ ├── mood/page.tsx # Mood tracking │ ├── targets/page.tsx # Target values dashboard │ └── api/ # 100+ API route files (admin, auth, measurements, medications, mood, insights, integrations, ingest, dashboard, feedback, tokens, notifications, monitoring, …) @@ -104,7 +108,8 @@ src/ │ ├── charts/ # Recharts wrappers, compliance charts │ ├── insights/ # AI insights cards (status, advisor) │ ├── gamification/ # Achievement cards, progress -│ ├── settings/ # Settings-page section components +│ ├── settings/ # Per-route Settings section components (one per /settings/[section]) +│ ├── admin/ # Per-route Admin section components (system-status, integrations, monitoring, reminders, users, audit, danger-zone, feedback) │ └── monitoring/ # Umami, GlitchTip bootstrap ├── lib/ │ ├── db.ts # Prisma client singleton @@ -135,8 +140,8 @@ messages/ ├── de.json # German translations (primary UI language) └── en.json # English translations prisma/ -├── schema.prisma # Database schema (25 models) -└── migrations/ # Migration files (0001–0024; latest: oxygen_saturation) +├── schema.prisma # Database schema (26 models) +└── migrations/ # Migration files (0001–0025; latest: refresh_tokens + user_locale_drift_fix) prisma.config.ts # Prisma config (DB URL lives here, NOT in schema) public/ ├── sw.js # Service worker (Web Push + offline caching) @@ -183,11 +188,11 @@ These are hard-won lessons. Ignoring them will cause errors: - **Zod v4**: Import from `"zod/v4"`, not `"zod"`. - **jsPDF**: Client-side only. Import dynamically in browser context. Used with `jspdf-autotable` plugin. -### Settings Page +### Settings & Admin (per-route layout, v1.4) -- One large file (~3150 lines), 8 top-level sections. Sidebar switches to "settings mode" showing section shortcuts. Splitting into per-section files is tracked for 1.4.0 — until then, ESLint `react-hooks/set-state-in-effect` stays non-blocking because of the long-standing violations in this file. -- Sections scroll-to with highlight animation (`section-highlight` CSS class). -- Top-level section IDs: `section-allgemein`, `section-sicherheit`, `section-benachrichtigungen`, `section-personalization`, `section-integration`, `section-api`, `section-export`, `section-danger-zone`. Sub-anchors inside those sections include `profil`, `passwort`, `passkeys`, `telegram`, `ntfy`, `web-push`, `insights`, `dashboard-layout`, `thresholds`, `withings`, `moodlog`, `api-tokens`, `api-endpoints`. +- **Settings**: 8 routes under `/settings/[section]` — `account`, `integrations`, `notifications`, `dashboard`, `ai`, `api`, `advanced`, `about`. The legacy `/settings` 308-redirects to `/settings/account`. Sidebar deep-links and the `` patterns from 1.3 still resolve via the redirect. +- **Admin**: `src/app/admin/page.tsx` is now a 77-LOC shell that mounts the per-section components in `src/components/admin/`. Status-card grid lives in `status-card-grid.tsx`. The aggregator endpoint `/api/admin/status-overview` returns the six-card summaries in one batched query. +- ESLint `react-hooks/set-state-in-effect` is **strict** now (was non-blocking when the settings monolith carried inline-effect state-setters). Use lazy `useState(() => …)` for localStorage reads, TanStack Query for data fetches. ### Insights Page @@ -225,7 +230,7 @@ These are hard-won lessons. Ignoring them will cause errors: ## Database Models (Prisma) -25 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. +26 models: `User`, `Passkey`, `Session`, `AuthChallenge`, `Measurement`, `Medication`, `MedicationSchedule`, `MedicationIntakeEvent`, `ReminderPhaseConfig`, `TelegramReminderMessage`, `TelegramScheduledDeletion`, `ApiToken`, `RefreshToken`, `WithingsConnection`, `MoodEntry`, `AppSettings`, `Feedback`, `AuditLog`, `NotificationChannel`, `NotificationPreference`, `PushSubscription`, `DataBackup`, `UserAchievement`, `RateLimit`, `Device`, `IdempotencyKey`. (`RefreshToken` added in v1.4.0 alongside the native-client 24h access-token / refresh-token rotation flow.) ## When Making Changes diff --git a/CLAUDE.md b/CLAUDE.md index e84f5a4..926755e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ docker compose logs -f app # Tail app logs ## Architecture - **Next.js 16** App Router with TypeScript strict. Pages are RSC by default; `"use client"` only for interactivity. -- **Prisma 7** ORM with PostgreSQL (25 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma). +- **Prisma 7** ORM with PostgreSQL (26 models). Uses `PrismaPg` adapter from `@prisma/adapter-pg`. Client singleton at `src/lib/db.ts`. Generated client at `src/generated/prisma/client` (note the `/client` suffix). Prisma config in `prisma.config.ts` (not in schema.prisma). - **shadcn/ui** components (new-york style) in `src/components/ui/`. Add new ones via `pnpm dlx shadcn@latest add `. - **Dracula theme** via CSS variables in `globals.css`. Dark mode is default. Use `--dracula-*` tokens for chart colors. - **TanStack Query** for client-side data fetching. Provider in `src/components/providers.tsx`. @@ -68,6 +68,8 @@ docker compose logs -f app # Tail app logs - `src/components/measurements/` — measurement form, list - `src/components/mood/` — mood form, mood list - `src/components/charts/` — Recharts wrappers +- `src/components/settings/` — per-route Settings section components (one per `/settings/[section]`) +- `src/components/admin/` — per-route Admin section components (system-status, integrations, monitoring, users, audit, danger-zone, feedback) - `src/lib/logging/` — Wide Events structured logging (types, config, event-builder, context, sampler, transports, background) - `src/lib/api-handler.ts` — apiHandler wrapper, requireAuth(), requireAdmin(), HttpError - `src/lib/` — server utilities (db, crypto, auth, analytics, export, rate-limit, gravatar) @@ -81,7 +83,9 @@ docker compose logs -f app # Tail app logs - `src/lib/validations/` — Zod schemas shared between API + client - `src/hooks/` — React hooks (`use-auth`) - `messages/de.json` + `messages/en.json` — i18n translations -- `prisma/schema.prisma` — database schema (25 models) +- `prisma/schema.prisma` — database schema (26 models) +- `tests/integration/` — Postgres testcontainers integration suite (rate-limit race, idempotency replay, GDPR cascade delete, session lifecycle); `pnpm test:integration` +- `e2e/` — Playwright + axe-core suite for public smoke checks (version, auth-redirect, login, locale-switch, a11y); `pnpm e2e` - `prisma.config.ts` — Prisma config (DB URL here, not in schema) - `public/sw.js` — Service worker for Web Push notifications + offline caching - `docs/` — long-form audit notes (`docs/audit/`); end-user docs live in the separate site at https://docs.healthlog.dev @@ -107,6 +111,8 @@ docker compose logs -f app # Tail app logs - **Achievements**: Persistent in `UserAchievement` table. Computed on API call, new unlocks written to DB with stable `unlockedAt` timestamps - **Data backup**: pg-boss `data-backup` queue runs weekly (Sundays 03:00), stores JSON in `DataBackup` model - **Wide Events / Structured Logging**: `apiHandler()` wraps all API routes. Use `annotate()` from `@/lib/logging/context` for business-action annotations. Use `requireAuth()` / `requireAdmin()` from `@/lib/api-handler` (auto-annotates auth). Background jobs use `withBackgroundEvent()`. External calls tracked via `getEvent()?.addExternalCall()`. No `console.log` in production code — use event annotations instead. Env vars: `LOG_LEVEL`, `LOG_SAMPLE_RATE`, `LOG_SLOW_THRESHOLD_MS`, `LOG_INCLUDE_STACK`, `LOKI_ENDPOINT`, `LOKI_USERNAME`, `LOKI_PASSWORD` +- **Multi-tenant prep (v1.4)**: `HEALTHLOG_PROCESS_TYPE=web|worker|all` (default `all`) splits HTTP and pg-boss workloads; the proxy refuses HTTP traffic with 503 + `X-HealthLog-Process-Type: worker` in worker mode. `ENCRYPTION_KEYS` is a JSON map of versioned keys (`{"v1": "...", "v2": "..."}`) plus `ENCRYPTION_ACTIVE_KEY_ID` for new writes; rotation via `pnpm tsx scripts/rotate-encryption-key.ts `. `BACKUP_S3_*` env block configures off-host weekly encrypted backups (PutObject + GetObject only — retention is the bucket's lifecycle policy, never the worker). +- **Native API clients (v1.4)**: `POST /api/auth/login` and `/api/auth/passkey/login-verify` issue a 24h access token (`hlk_<64hex>`) AND a refresh token (`hlr_<64hex>`) when `X-Client-Type: native` or the User-Agent starts with `HealthLog-iOS/`. The browser flow is unchanged. Refresh-token reuse-detection revokes every refresh token for the user. The idempotency replay-cache rejects bodies containing `hlk_` OR `hlr_` so cached responses can never echo a token back. ## Headless-Client API Patterns diff --git a/README.md b/README.md index a8fe961..f392ee8 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,10 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a **Internationalization** -- English (default) and German UI with 1500+ translation keys, guarded by a CI integrity test that fails the build on duplicate keys or drift between locales. Numbers, dates, units, and AI prompts all locale-aware via `useFormatters()`. Browser-based detection with per-user override. +**Multi-tenant ready** _(v1.4)_ — Off-host AES-GCM-encrypted weekly backups to any S3-compatible bucket (R2, B2, MinIO, AWS), encryption-key versioning + zero-downtime rotation CLI, optional `HEALTHLOG_PROCESS_TYPE=web|worker|all` so HTTP and pg-boss can scale independently, and short-lived 24h access tokens with refresh-token rotation for native API clients. The browser cookie session is unchanged. + +**Test connection buttons** _(v1.4)_ — One-click probes for Withings, moodLog.app, Web Push, Glitchtip, and Umami in addition to the existing AI / Telegram / ntfy tests. Each one rate-limited, sanitised against SSRF redirects, and surfaces a localisable `errorCode` so the UI can render the failure in the user's language. + --- ## Quick Start @@ -102,7 +106,7 @@ Open **http://localhost:3000**. The first registered user becomes admin. | ------------- | ------------------------------------------------- | | Framework | Next.js 16 (App Router, React Server Components) | | Language | TypeScript (strict mode) | -| Database | PostgreSQL 16 + Prisma 7 (25 models) | +| Database | PostgreSQL 16 + Prisma 7 (26 models) | | Job Queue | pg-boss 12 (reminders, insights, backups) | | UI | shadcn/ui, Tailwind CSS 4, Radix UI, Lucide Icons | | Charts | Recharts 3 | @@ -275,83 +279,101 @@ All mutations require authentication via session cookie. External ingest uses Be
Auth and Integrations -| Method | Endpoint | Description | -| ------ | -------------------------------- | ------------------------------------ | -| `POST` | `/api/auth/register` | Create account | -| `POST` | `/api/auth/login` | Password login | -| `POST` | `/api/auth/logout` | Destroy session | -| `GET` | `/api/auth/me` | Current user profile + Gravatar URL | -| `POST` | `/api/auth/password` | Change password | -| `PATCH`| `/api/auth/profile` | Update profile fields | -| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) | -| `GET` | `/api/auth/passkeys` | List enrolled passkeys | -| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start | -| `GET` | `/api/withings/connect` | Initiate Withings OAuth | -| `POST` | `/api/withings/sync` | Trigger manual Withings sync | -| `POST` | `/api/withings/webhook` | Withings notification webhook | -| `POST` | `/api/insights/generate` | Regenerate AI insights | -| `GET` | `/api/insights/comprehensive` | Aggregated insight payload | -| `GET` | `/api/gamification/achievements` | Achievement progress | -| `GET` | `/api/health` | Docker health check | +| Method | Endpoint | Description | +| ------- | -------------------------------- | ----------------------------------- | +| `POST` | `/api/auth/register` | Create account | +| `POST` | `/api/auth/login` | Password login | +| `POST` | `/api/auth/logout` | Destroy session | +| `GET` | `/api/auth/me` | Current user profile + Gravatar URL | +| `POST` | `/api/auth/password` | Change password | +| `PATCH` | `/api/auth/profile` | Update profile fields | +| `POST` | `/api/auth/passkey/*` | WebAuthn flows (4 sub-routes) | +| `GET` | `/api/auth/passkeys` | List enrolled passkeys | +| `GET` | `/api/auth/codex/authorize` | ChatGPT (codex) OAuth start | +| `GET` | `/api/withings/connect` | Initiate Withings OAuth | +| `POST` | `/api/withings/sync` | Trigger manual Withings sync | +| `POST` | `/api/withings/webhook` | Withings notification webhook | +| `POST` | `/api/insights/generate` | Regenerate AI insights | +| `GET` | `/api/insights/comprehensive` | Aggregated insight payload | +| `GET` | `/api/gamification/achievements` | Achievement progress | +| `GET` | `/api/health` | Docker health check |
Personalization (Thresholds + Dashboard) -| Method | Endpoint | Description | -| -------- | ------------------------- | --------------------------------------------- | -| `GET` | `/api/user/thresholds` | Read per-user threshold overrides | -| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged)| -| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged)| -| `GET` | `/api/dashboard/widgets` | Read dashboard layout | -| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) | -| `POST` | `/api/onboarding/complete`| Mark onboarding finished | +| Method | Endpoint | Description | +| ------ | -------------------------- | ---------------------------------------------- | +| `GET` | `/api/user/thresholds` | Read per-user threshold overrides | +| `PUT` | `/api/user/thresholds` | Upsert thresholds (rate-limited, audit-logged) | +| `GET` | `/api/insights/targets` | Effective ranges (defaults + overrides merged) | +| `GET` | `/api/dashboard/widgets` | Read dashboard layout | +| `PUT` | `/api/dashboard/widgets` | Persist dashboard layout (show/hide/reorder) | +| `POST` | `/api/onboarding/complete` | Mark onboarding finished |
Feedback + API Tokens -| Method | Endpoint | Description | -| -------- | --------------------------------------- | ------------------------------------ | -| `POST` | `/api/feedback` | Submit in-app feedback | -| `GET` | `/api/bugreport/status` | Check published GitHub issue state | -| `GET` | `/api/tokens` | List own API tokens | -| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) | -| `DELETE` | `/api/tokens/:id` | Revoke API token | +| Method | Endpoint | Description | +| -------- | ----------------------- | ----------------------------------- | +| `POST` | `/api/feedback` | Submit in-app feedback | +| `GET` | `/api/bugreport/status` | Check published GitHub issue state | +| `GET` | `/api/tokens` | List own API tokens | +| `POST` | `/api/tokens` | Mint new API token (Bearer, hashed) | +| `DELETE` | `/api/tokens/:id` | Revoke API token |
Notifications -| Method | Endpoint | Description | -| -------- | --------------------------------- | -------------------------------------- | -| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix | -| `PUT` | `/api/notifications/preferences` | Update preferences | -| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push | -| `POST` | `/api/notifications/web-push` | Register Web Push subscription | -| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback | +| Method | Endpoint | Description | +| ------ | -------------------------------- | ----------------------------------- | +| `GET` | `/api/notifications/preferences` | Read per-channel × per-event matrix | +| `PUT` | `/api/notifications/preferences` | Update preferences | +| `GET` | `/api/notifications/vapid` | VAPID public key for Web Push | +| `POST` | `/api/notifications/web-push` | Register Web Push subscription | +| `POST` | `/api/telegram/webhook` | Telegram bot inline-button callback |
Admin (admin role required) -| Method | Endpoint | Description | -| -------- | --------------------------------- | -------------------------------------- | -| `GET` | `/api/admin/status` | System + integration status | -| `GET` | `/api/admin/users` | List users | -| `POST` | `/api/admin/users/:id/reset-password` | Force password reset | -| `GET` | `/api/admin/feedback` | All feedback / bug reports | -| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue | -| `GET` | `/api/admin/audit-log` | Audit-log viewer | -| `GET` | `/api/admin/ai-settings` | Read shared AI provider config | -| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config | -| `GET` | `/api/admin/tokens` | All issued API tokens | -| `POST` | `/api/admin/notifications/test` | Send test notification | -| `GET` | `/api/admin/data` | Data backups + counts | +| Method | Endpoint | Description | +| ------ | ------------------------------------- | ------------------------------------- | +| `GET` | `/api/admin/status` | System + integration status | +| `GET` | `/api/admin/users` | List users | +| `POST` | `/api/admin/users/:id/reset-password` | Force password reset | +| `GET` | `/api/admin/feedback` | All feedback / bug reports | +| `POST` | `/api/admin/feedback/:id/github` | Escalate feedback to GitHub issue | +| `GET` | `/api/admin/audit-log` | Audit-log viewer | +| `GET` | `/api/admin/ai-settings` | Read shared AI provider config | +| `PUT` | `/api/admin/ai-settings` | Update shared AI provider config | +| `GET` | `/api/admin/tokens` | All issued API tokens | +| `POST` | `/api/admin/notifications/test` | Send test notification | +| `GET` | `/api/admin/data` | Data backups + counts | +| `GET` | `/api/admin/status-overview` | Aggregated status for the 6-card grid | +| `POST` | `/api/admin/backup/test` | Probe S3-compatible backup target | + +
+ +
+Public + v1.4 additions + +| Method | Endpoint | Description | +| ------ | ---------------------------------- | ----------------------------------------------- | +| `GET` | `/api/version` | Public — version + build SHA + license, no auth | +| `POST` | `/api/integrations/withings/test` | Probe a saved Withings connection | +| `POST` | `/api/integrations/moodlog/test` | Probe moodLog.app webhook reachability | +| `POST` | `/api/notifications/web-push/test` | Send a test Web Push to the current user | +| `POST` | `/api/monitoring/glitchtip/test` | Trigger a Glitchtip ingest probe | +| `POST` | `/api/monitoring/umami/test` | Verify Umami script + website ID resolve | +| `POST` | `/api/auth/refresh` | Native client refresh-token rotation | +| `POST` | `/api/auth/refresh/revoke` | Revoke an issued refresh token |
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 05fa620..f0715aa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -116,19 +116,19 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/RegisterInput' } + schema: { $ref: "#/components/schemas/RegisterInput" } responses: - '201': + "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' } + $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: @@ -140,17 +140,17 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/LoginInput' } + schema: { $ref: "#/components/schemas/LoginInput" } responses: - '200': + "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' } + 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: @@ -158,20 +158,20 @@ paths: summary: Destroy current session operationId: logoutUser responses: - '200': + "200": description: Logged out content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: loggedOut: { type: boolean } - '500': { $ref: '#/components/responses/ServerError' } + "500": { $ref: "#/components/responses/ServerError" } /api/auth/me: get: @@ -179,18 +179,18 @@ paths: summary: Get current authenticated user operationId: getCurrentUser responses: - '200': + "200": description: Current user content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/CurrentUser' } - '401': { $ref: '#/components/responses/Unauthorized' } - '500': { $ref: '#/components/responses/ServerError' } + data: { $ref: "#/components/schemas/CurrentUser" } + "401": { $ref: "#/components/responses/Unauthorized" } + "500": { $ref: "#/components/responses/ServerError" } /api/auth/profile: put: @@ -201,22 +201,22 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/ProfileInput' } + schema: { $ref: "#/components/schemas/ProfileInput" } responses: - '200': + "200": description: Updated profile content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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: @@ -227,26 +227,26 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/ChangePasswordInput' } + schema: { $ref: "#/components/schemas/ChangePasswordInput" } responses: - '200': + "200": description: Password changed content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + "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: @@ -255,13 +255,13 @@ paths: operationId: getRegistrationStatus security: [] responses: - '200': + "200": description: Registration status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -276,17 +276,18 @@ paths: summary: Begin passkey registration — get WebAuthn options operationId: getPasskeyRegisterOptions responses: - '200': + "200": description: WebAuthn registration options content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/PasskeyRegisterOptions' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: + { $ref: "#/components/schemas/PasskeyRegisterOptions" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/auth/passkey/register-verify: post: @@ -297,24 +298,25 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/PasskeyVerifyRegistrationInput' } + schema: + { $ref: "#/components/schemas/PasskeyVerifyRegistrationInput" } responses: - '200': + "200": description: Passkey registered content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } /api/auth/passkey/login-options: post: @@ -323,17 +325,17 @@ paths: operationId: getPasskeyLoginOptions security: [] responses: - '200': + "200": description: WebAuthn authentication options content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/PasskeyLoginOptions' } - '429': { $ref: '#/components/responses/RateLimited' } + data: { $ref: "#/components/schemas/PasskeyLoginOptions" } + "429": { $ref: "#/components/responses/RateLimited" } /api/auth/passkey/login-verify: post: @@ -345,17 +347,17 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/PasskeyVerifyLoginInput' } + schema: { $ref: "#/components/schemas/PasskeyVerifyLoginInput" } responses: - '200': + "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' } + 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: @@ -363,19 +365,19 @@ paths: summary: List the user's passkeys operationId: listPasskeys responses: - '200': + "200": description: List of passkeys content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: array - items: { $ref: '#/components/schemas/PasskeySummary' } - '401': { $ref: '#/components/responses/Unauthorized' } + items: { $ref: "#/components/schemas/PasskeySummary" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/auth/passkeys/{id}: delete: @@ -383,24 +385,24 @@ paths: summary: Delete a passkey operationId: deletePasskey parameters: - - $ref: '#/components/parameters/IdPathParam' + - $ref: "#/components/parameters/IdPathParam" responses: - '200': + "200": description: Deleted content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } # ─── Codex (ChatGPT) OAuth ──────────────────────────────────── /api/auth/codex/authorize: @@ -409,10 +411,10 @@ paths: summary: Begin ChatGPT OAuth flow (redirects to ChatGPT) operationId: codexAuthorize responses: - '302': + "302": description: Redirect to ChatGPT auth URL - '401': { $ref: '#/components/responses/Unauthorized' } - '429': { $ref: '#/components/responses/RateLimited' } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } /api/auth/codex/callback: get: @@ -430,9 +432,9 @@ paths: name: error schema: { type: string } responses: - '302': + "302": description: Redirect back to /settings with status - '401': { $ref: '#/components/responses/Unauthorized' } + "401": { $ref: "#/components/responses/Unauthorized" } /api/auth/codex/disconnect: delete: @@ -440,21 +442,21 @@ paths: summary: Disconnect ChatGPT integration operationId: codexDisconnect responses: - '200': + "200": description: Disconnected content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: disconnected: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } - '429': { $ref: '#/components/responses/RateLimited' } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } # ─── Measurements ──────────────────────────────────────────────── /api/measurements: @@ -465,15 +467,15 @@ paths: parameters: - in: query name: type - schema: { $ref: '#/components/schemas/MeasurementType' } + 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' + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" - in: query name: sortBy schema: @@ -487,13 +489,13 @@ paths: enum: [asc, desc] default: desc responses: - '200': + "200": description: List of measurements content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -501,11 +503,11 @@ paths: 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' } + 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) @@ -516,51 +518,51 @@ paths: application/json: schema: oneOf: - - $ref: '#/components/schemas/CreateMeasurementInput' + - $ref: "#/components/schemas/CreateMeasurementInput" - type: array - items: { $ref: '#/components/schemas/CreateMeasurementInput' } + items: { $ref: "#/components/schemas/CreateMeasurementInput" } minItems: 1 maxItems: 5 responses: - '201': + "201": description: Created content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: oneOf: - - $ref: '#/components/schemas/Measurement' + - $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' } + 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' + - $ref: "#/components/parameters/IdPathParam" get: tags: [Measurements] summary: Get a single measurement operationId: getMeasurement responses: - '200': + "200": description: Measurement content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/Measurement' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + data: { $ref: "#/components/schemas/Measurement" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } put: tags: [Measurements] summary: Update a measurement @@ -569,33 +571,33 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/UpdateMeasurementInput' } + schema: { $ref: "#/components/schemas/UpdateMeasurementInput" } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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': + "200": description: Deleted content: application/json: - schema: { $ref: '#/components/schemas/DeletedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + schema: { $ref: "#/components/schemas/DeletedEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } # ─── Medications ───────────────────────────────────────────────── /api/medications: @@ -604,19 +606,22 @@ paths: summary: List medications (with today's intake summary) operationId: listMedications responses: - '200': + "200": description: List content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: array - items: { $ref: '#/components/schemas/MedicationWithSchedules' } - '401': { $ref: '#/components/responses/Unauthorized' } + items: + { + $ref: "#/components/schemas/MedicationWithSchedules", + } + "401": { $ref: "#/components/responses/Unauthorized" } post: tags: [Medications] summary: Create a medication with schedules @@ -625,41 +630,43 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/CreateMedicationInput' } + schema: { $ref: "#/components/schemas/CreateMedicationInput" } responses: - '201': + "201": description: Created content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MedicationWithSchedules' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + data: + { $ref: "#/components/schemas/MedicationWithSchedules" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } /api/medications/{id}: parameters: - - $ref: '#/components/parameters/IdPathParam' + - $ref: "#/components/parameters/IdPathParam" get: tags: [Medications] summary: Get a single medication operationId: getMedication responses: - '200': + "200": description: Medication content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MedicationWithSchedules' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + data: + { $ref: "#/components/schemas/MedicationWithSchedules" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } put: tags: [Medications] summary: Update a medication @@ -668,44 +675,45 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/UpdateMedicationInput' } + schema: { $ref: "#/components/schemas/UpdateMedicationInput" } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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': + "200": description: Deleted content: application/json: - schema: { $ref: '#/components/schemas/DeletedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + schema: { $ref: "#/components/schemas/DeletedEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } /api/medications/{id}/intake: parameters: - - $ref: '#/components/parameters/IdPathParam' + - $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' + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" - in: query name: sortBy schema: @@ -716,13 +724,13 @@ paths: name: sortDir schema: { type: string, enum: [asc, desc], default: desc } responses: - '200': + "200": description: Events content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -730,11 +738,14 @@ paths: 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' } + 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 @@ -743,35 +754,37 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/CreateIntakeInput' } + schema: { $ref: "#/components/schemas/CreateIntakeInput" } responses: - '200': + "200": description: Idempotent return of existing event content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MedicationIntakeEvent' } - '201': + data: + { $ref: "#/components/schemas/MedicationIntakeEvent" } + "201": description: Created content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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' + - $ref: "#/components/parameters/IdPathParam" - in: path name: eventId required: true @@ -784,37 +797,38 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/UpdateIntakeInput' } + schema: { $ref: "#/components/schemas/UpdateIntakeInput" } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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': + "200": description: Deleted content: application/json: - schema: { $ref: '#/components/schemas/DeletedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + 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' + - $ref: "#/components/parameters/IdPathParam" post: tags: [Medications] summary: Bulk-import historical intakes for one medication @@ -826,42 +840,42 @@ paths: schema: oneOf: - type: array - items: { $ref: '#/components/schemas/IntakeImportEntry' } + 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': + "201": description: Import result content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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' + - $ref: "#/components/parameters/IdPathParam" delete: tags: [Medications] summary: Delete ALL intake events for a medication operationId: purgeMedicationIntakes responses: - '200': + "200": description: Purged content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -869,50 +883,53 @@ paths: properties: purged: { type: boolean } count: { type: integer } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } /api/medications/{id}/compliance: parameters: - - $ref: '#/components/parameters/IdPathParam' + - $ref: "#/components/parameters/IdPathParam" get: tags: [Medications] summary: Compliance summary for a medication (7d, 30d, 90 daily) operationId: getMedicationCompliance responses: - '200': + "200": description: Compliance content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MedicationComplianceResponse' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + 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' + - $ref: "#/components/parameters/IdPathParam" get: tags: [Medications] summary: Get reminder-phase config (or defaults) operationId: getPhaseConfig responses: - '200': + "200": description: Phase config content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/ReminderPhaseConfig' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + data: { $ref: "#/components/schemas/ReminderPhaseConfig" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } put: tags: [Medications] summary: Update reminder-phase config @@ -921,57 +938,57 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/ReminderPhaseConfig' } + schema: { $ref: "#/components/schemas/ReminderPhaseConfig" } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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': + "200": description: Reset content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: reset: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } /api/medications/{id}/api-endpoint: parameters: - - $ref: '#/components/parameters/IdPathParam' + - $ref: "#/components/parameters/IdPathParam" get: tags: [Medications] summary: Whether an external API token is enabled for this medication operationId: getMedicationApiEndpoint responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -979,9 +996,9 @@ paths: properties: enabled: { type: boolean } activeTokenCount: { type: integer } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - '404': { $ref: '#/components/responses/NotFound' } + "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 @@ -996,30 +1013,36 @@ paths: enabled: { type: boolean } required: [enabled] responses: - '200': + "200": description: Existing token reused or revocation result content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MedicationApiEndpointResponse' } - '201': + data: + { + $ref: "#/components/schemas/MedicationApiEndpointResponse", + } + "201": description: New medication-scoped token created content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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: @@ -1034,18 +1057,19 @@ paths: name: to schema: { type: string, format: date-time } responses: - '200': + "200": description: Summary content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/IntakeSummaryResponse' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + data: + { $ref: "#/components/schemas/IntakeSummaryResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } # ─── Mood ───────────────────────────────────────────────────── /api/mood-entries: @@ -1056,17 +1080,17 @@ paths: parameters: - in: query name: mood - schema: { $ref: '#/components/schemas/MoodLevel' } + schema: { $ref: "#/components/schemas/MoodLevel" } - in: query name: from - description: 'YYYY-MM-DD lower bound (inclusive).' + description: "YYYY-MM-DD lower bound (inclusive)." schema: { type: string } - in: query name: to - description: 'YYYY-MM-DD upper bound (inclusive).' + description: "YYYY-MM-DD upper bound (inclusive)." schema: { type: string } - - $ref: '#/components/parameters/LimitParam' - - $ref: '#/components/parameters/OffsetParam' + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" - in: query name: sortBy schema: { type: string, default: moodLoggedAt } @@ -1074,13 +1098,13 @@ paths: name: sortDir schema: { type: string, enum: [asc, desc], default: desc } responses: - '200': + "200": description: Mood entries content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -1088,10 +1112,10 @@ paths: properties: entries: type: array - items: { $ref: '#/components/schemas/MoodEntry' } - meta: { $ref: '#/components/schemas/PageMeta' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + 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 @@ -1100,42 +1124,42 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/CreateMoodEntryInput' } + schema: { $ref: "#/components/schemas/CreateMoodEntryInput" } responses: - '201': + "201": description: Created content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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' + - $ref: "#/components/parameters/IdPathParam" get: tags: [Mood] summary: Get a mood entry operationId: getMoodEntry responses: - '200': + "200": description: Mood entry content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MoodEntry' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + data: { $ref: "#/components/schemas/MoodEntry" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } put: tags: [Mood] summary: Update a mood entry @@ -1144,33 +1168,33 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/UpdateMoodEntryInput' } + schema: { $ref: "#/components/schemas/UpdateMoodEntryInput" } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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': + "200": description: Deleted content: application/json: - schema: { $ref: '#/components/schemas/DeletedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + schema: { $ref: "#/components/schemas/DeletedEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } /api/mood/analytics: get: @@ -1178,17 +1202,17 @@ paths: summary: Mood analytics (daily averages + summary) operationId: getMoodAnalytics responses: - '200': + "200": description: Analytics content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MoodAnalytics' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/MoodAnalytics" } + "401": { $ref: "#/components/responses/Unauthorized" } # ─── Dashboard ──────────────────────────────────────────────── /api/dashboard/widgets: @@ -1197,17 +1221,17 @@ paths: summary: Get the resolved dashboard widget layout operationId: getDashboardWidgets responses: - '200': + "200": description: Layout content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/DashboardLayout' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/DashboardLayout" } + "401": { $ref: "#/components/responses/Unauthorized" } put: tags: [Dashboard] summary: Replace the dashboard widget layout @@ -1216,36 +1240,36 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/DashboardLayout' } + schema: { $ref: "#/components/schemas/DashboardLayout" } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/DashboardLayout' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + 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': + "200": description: Reset content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/DashboardLayout' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/DashboardLayout" } + "401": { $ref: "#/components/responses/Unauthorized" } # ─── Insights ──────────────────────────────────────────────── /api/insights/comprehensive: @@ -1254,17 +1278,18 @@ paths: summary: Comprehensive 90-day analytics bundle operationId: getComprehensiveInsights responses: - '200': + "200": description: Insights bundle content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/ComprehensiveInsights' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: + { $ref: "#/components/schemas/ComprehensiveInsights" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/insights/generate: post: @@ -1278,26 +1303,27 @@ paths: schema: type: object properties: - force: { type: boolean, description: 'Bypass 24h cache' } + force: { type: boolean, description: "Bypass 24h cache" } responses: - '200': + "200": description: Insights content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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': + 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' } + schema: { $ref: "#/components/schemas/ApiError" } /api/insights/settings: get: @@ -1305,17 +1331,17 @@ paths: summary: Get insights settings (privacy mode, provider status) operationId: getInsightsSettings responses: - '200': + "200": description: Settings content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/InsightsSettings' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/InsightsSettings" } + "401": { $ref: "#/components/responses/Unauthorized" } put: tags: [Insights] summary: Update insights settings (privacy mode) @@ -1329,21 +1355,21 @@ paths: properties: privacyMode: { type: string, enum: [aggregated, raw] } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: updated: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } /api/insights/targets: get: @@ -1351,17 +1377,18 @@ paths: summary: Per-metric target ranges + classification operationId: getInsightTargets responses: - '200': + "200": description: Targets content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/InsightTargetsResponse' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: + { $ref: "#/components/schemas/InsightTargetsResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/insights/blood-pressure-status: get: @@ -1369,19 +1396,19 @@ paths: summary: BP-specific narrative status operationId: getBloodPressureStatus parameters: - - $ref: '#/components/parameters/LocaleParam' + - $ref: "#/components/parameters/LocaleParam" responses: - '200': + "200": description: BP status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/InsightStatusBundle' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/InsightStatusBundle" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/insights/bmi-status: get: @@ -1389,19 +1416,19 @@ paths: summary: BMI narrative status operationId: getBmiStatus parameters: - - $ref: '#/components/parameters/LocaleParam' + - $ref: "#/components/parameters/LocaleParam" responses: - '200': + "200": description: BMI status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/InsightStatusBundle' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/InsightStatusBundle" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/insights/general-status: get: @@ -1409,19 +1436,19 @@ paths: summary: General health narrative status operationId: getGeneralStatus parameters: - - $ref: '#/components/parameters/LocaleParam' + - $ref: "#/components/parameters/LocaleParam" responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/InsightStatusBundle' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/InsightStatusBundle" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/insights/medication-compliance-status: get: @@ -1429,19 +1456,19 @@ paths: summary: Medication compliance narrative status operationId: getMedicationComplianceStatus parameters: - - $ref: '#/components/parameters/LocaleParam' + - $ref: "#/components/parameters/LocaleParam" responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/InsightStatusBundle' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/InsightStatusBundle" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/insights/mood-status: get: @@ -1449,19 +1476,19 @@ paths: summary: Mood narrative status operationId: getMoodStatus parameters: - - $ref: '#/components/parameters/LocaleParam' + - $ref: "#/components/parameters/LocaleParam" responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/InsightStatusBundle' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/InsightStatusBundle" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/insights/pulse-status: get: @@ -1469,19 +1496,19 @@ paths: summary: Pulse narrative status operationId: getPulseStatus parameters: - - $ref: '#/components/parameters/LocaleParam' + - $ref: "#/components/parameters/LocaleParam" responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/InsightStatusBundle' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/InsightStatusBundle" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/insights/weight-status: get: @@ -1489,19 +1516,19 @@ paths: summary: Weight narrative status operationId: getWeightStatus parameters: - - $ref: '#/components/parameters/LocaleParam' + - $ref: "#/components/parameters/LocaleParam" responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/InsightStatusBundle' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/InsightStatusBundle" } + "401": { $ref: "#/components/responses/Unauthorized" } # ─── Achievements ───────────────────────────────────────────── /api/gamification/achievements: @@ -1510,17 +1537,17 @@ paths: summary: Get achievements progress + unlocks operationId: getAchievements responses: - '200': + "200": description: Achievements content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/AchievementsResult' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/AchievementsResult" } + "401": { $ref: "#/components/responses/Unauthorized" } # ─── DoctorReport ───────────────────────────────────────────── /api/doctor-report: @@ -1541,18 +1568,19 @@ paths: maximum: 365 default: 90 responses: - '200': + "200": description: Report data content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/DoctorReportResponse' } - '401': { $ref: '#/components/responses/Unauthorized' } - '429': { $ref: '#/components/responses/RateLimited' } + data: + { $ref: "#/components/schemas/DoctorReportResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } # ─── Onboarding ─────────────────────────────────────────────── /api/onboarding/complete: @@ -1564,23 +1592,23 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/OnboardingInput' } + schema: { $ref: "#/components/schemas/OnboardingInput" } responses: - '200': + "200": description: Completed content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: completed: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } # ─── Health probe ───────────────────────────────────────────── /api/health: @@ -1590,16 +1618,16 @@ paths: operationId: healthCheck security: [] responses: - '200': + "200": description: Healthy content: application/json: - schema: { $ref: '#/components/schemas/HealthStatus' } - '503': + schema: { $ref: "#/components/schemas/HealthStatus" } + "503": description: Degraded content: application/json: - schema: { $ref: '#/components/schemas/HealthStatus' } + schema: { $ref: "#/components/schemas/HealthStatus" } # ─── Tokens ────────────────────────────────────────────────── /api/tokens: @@ -1608,20 +1636,20 @@ paths: summary: List the user's API tokens (metadata only — secrets never returned) operationId: listTokens responses: - '200': + "200": description: Tokens content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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) @@ -1630,50 +1658,50 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/CreateTokenInput' } + schema: { $ref: "#/components/schemas/CreateTokenInput" } responses: - '201': + "201": description: Created (token visible once) content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: - token: { type: string, example: 'hlk_' } + token: { type: string, example: "hlk_" } name: { type: string } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - '422': { $ref: '#/components/responses/Unprocessable' } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } /api/tokens/{id}: parameters: - - $ref: '#/components/parameters/IdPathParam' + - $ref: "#/components/parameters/IdPathParam" delete: tags: [Tokens] summary: Revoke an API token operationId: revokeToken responses: - '200': + "200": description: Revoked content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } # ─── Ingest (external) ──────────────────────────────────────── /api/ingest/medication: @@ -1687,33 +1715,35 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/ExternalIntakeInput' } + schema: { $ref: "#/components/schemas/ExternalIntakeInput" } responses: - '200': + "200": description: Existing event returned (idempotent replay) content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MedicationIntakeEvent' } - '201': + data: + { $ref: "#/components/schemas/MedicationIntakeEvent" } + "201": description: Created content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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: @@ -1722,17 +1752,20 @@ paths: summary: List notification channels + preferences operationId: getNotificationPreferences responses: - '200': + "200": description: Preferences content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/NotificationPreferencesResponse' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: + { + $ref: "#/components/schemas/NotificationPreferencesResponse", + } + "401": { $ref: "#/components/responses/Unauthorized" } put: tags: [Notifications] summary: Toggle a single channel/event preference @@ -1741,21 +1774,23 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/UpdateNotificationPreferenceInput' } + schema: + { $ref: "#/components/schemas/UpdateNotificationPreferenceInput" } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + data: + { $ref: "#/components/schemas/NotificationPreference" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } + "422": { $ref: "#/components/responses/Unprocessable" } /api/notifications/vapid: get: @@ -1764,24 +1799,24 @@ paths: operationId: getVapidKey security: [] responses: - '200': + "200": description: VAPID key content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: publicKey: { type: string } - '503': + "503": description: Not configured content: application/json: - schema: { $ref: '#/components/schemas/ApiError' } + schema: { $ref: "#/components/schemas/ApiError" } /api/notifications/web-push: post: @@ -1792,23 +1827,23 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/WebPushSubscribeInput' } + schema: { $ref: "#/components/schemas/WebPushSubscribeInput" } responses: - '200': + "200": description: Subscribed content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: subscribed: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } delete: tags: [Notifications] summary: Unsubscribe a Web Push endpoint @@ -1823,21 +1858,21 @@ paths: endpoint: { type: string, format: uri } required: [endpoint] responses: - '200': + "200": description: Unsubscribed content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: unsubscribed: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } # ─── User configuration ─────────────────────────────────────── /api/user/ai-provider: @@ -1846,17 +1881,17 @@ paths: summary: Get user-level AI provider config operationId: getUserAiProvider responses: - '200': + "200": description: Config content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/UserAiProvider' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/UserAiProvider" } + "401": { $ref: "#/components/responses/Unauthorized" } patch: tags: [User] summary: Update user-level AI provider config @@ -1865,23 +1900,23 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/UpdateUserAiProviderInput' } + schema: { $ref: "#/components/schemas/UpdateUserAiProviderInput" } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: updated: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } /api/user/thresholds: get: @@ -1889,18 +1924,18 @@ paths: summary: Get effective + override threshold ranges operationId: getThresholds responses: - '200': + "200": description: Thresholds content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/ThresholdsResponse' } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + 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) @@ -1909,23 +1944,24 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/ThresholdOverrides' } + schema: { $ref: "#/components/schemas/ThresholdOverrides" } responses: - '200': + "200": description: Updated content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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) @@ -1934,23 +1970,24 @@ paths: - in: query name: metric schema: { type: string } - description: 'If supplied, only this metric is reset; otherwise all overrides are cleared.' + description: "If supplied, only this metric is reset; otherwise all overrides are cleared." responses: - '200': + "200": description: Reset content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + overrides: + { $ref: "#/components/schemas/ThresholdOverrides" } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } # ─── Analytics + AI test ────────────────────────────────────── /api/analytics: @@ -1959,17 +1996,17 @@ paths: summary: Per-type measurement summaries + BMI + BP-in-target operationId: getAnalytics responses: - '200': + "200": description: Analytics content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/AnalyticsResponse' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/AnalyticsResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/ai/test: post: @@ -1977,24 +2014,24 @@ paths: summary: Test the configured AI provider operationId: testAiProvider responses: - '200': + "200": description: AI test result content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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': + 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' } + schema: { $ref: "#/components/schemas/ApiError" } # ─── Export / Import ────────────────────────────────────────── /api/export: @@ -2013,7 +2050,7 @@ paths: enum: [measurements, medications, intake, mood, all] default: all responses: - '200': + "200": description: Export payload content: application/json: @@ -2025,9 +2062,9 @@ paths: additionalProperties: true text/csv: schema: { type: string } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } - '429': { $ref: '#/components/responses/RateLimited' } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } /api/import: post: @@ -2038,20 +2075,20 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/ImportInput' } + schema: { $ref: "#/components/schemas/ImportInput" } responses: - '200': + "200": description: Import stats content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/ImportResult' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + data: { $ref: "#/components/schemas/ImportResult" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } # ─── Audit log + Feedback + Bug report ──────────────────────── /api/audit-log: @@ -2060,20 +2097,20 @@ paths: summary: Personal audit log entries operationId: getAuditLog parameters: - - $ref: '#/components/parameters/LimitParam' - - $ref: '#/components/parameters/OffsetParam' + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" responses: - '200': + "200": description: Entries content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/AuditLogResponse' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/AuditLogResponse" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/feedback: get: @@ -2081,16 +2118,16 @@ paths: summary: List the user's submitted feedback operationId: listFeedback parameters: - - $ref: '#/components/parameters/LimitParam' - - $ref: '#/components/parameters/OffsetParam' + - $ref: "#/components/parameters/LimitParam" + - $ref: "#/components/parameters/OffsetParam" responses: - '200': + "200": description: Feedback content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -2098,9 +2135,10 @@ paths: properties: items: type: array - items: { $ref: '#/components/schemas/FeedbackSummary' } - meta: { $ref: '#/components/schemas/PageMeta' } - '401': { $ref: '#/components/responses/Unauthorized' } + items: + { $ref: "#/components/schemas/FeedbackSummary" } + meta: { $ref: "#/components/schemas/PageMeta" } + "401": { $ref: "#/components/responses/Unauthorized" } post: tags: [Feedback] summary: Submit feedback (with optional screenshot) @@ -2109,21 +2147,21 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/CreateFeedbackInput' } + schema: { $ref: "#/components/schemas/CreateFeedbackInput" } responses: - '201': + "201": description: Created content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + data: { $ref: "#/components/schemas/FeedbackSummary" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } /api/bugreport: post: @@ -2134,15 +2172,15 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/BugReportInput' } + schema: { $ref: "#/components/schemas/BugReportInput" } responses: - '200': + "200": description: Issue created content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -2150,15 +2188,15 @@ paths: 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': + "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' } + schema: { $ref: "#/components/schemas/ApiError" } /api/bugreport/status: get: @@ -2166,13 +2204,13 @@ paths: summary: Whether bug-report submission is configured operationId: getBugReportStatus responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -2180,8 +2218,8 @@ paths: properties: configured: { type: boolean } isAdmin: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } - '429': { $ref: '#/components/responses/RateLimited' } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } # ─── Settings (per-user) ────────────────────────────────────── /api/settings/account: @@ -2190,12 +2228,12 @@ paths: summary: Delete the user's account (and all data) operationId: deleteAccount responses: - '200': + "200": description: Account deleted content: application/json: - schema: { $ref: '#/components/schemas/DeletedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } + schema: { $ref: "#/components/schemas/DeletedEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/settings/data: delete: @@ -2203,12 +2241,12 @@ paths: summary: Wipe the user's data (keep account) operationId: wipeData responses: - '200': + "200": description: Data wiped content: application/json: - schema: { $ref: '#/components/schemas/DeletedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } + schema: { $ref: "#/components/schemas/DeletedEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/settings/global-services: get: @@ -2216,17 +2254,17 @@ paths: summary: Global service availability flags (telegram, ntfy, web-push, …) operationId: getGlobalServices responses: - '200': + "200": description: Flags content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/GlobalServicesFlags' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/GlobalServicesFlags" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/settings/moodlog: put: @@ -2237,26 +2275,26 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/MoodLogSettingsInput' } + schema: { $ref: "#/components/schemas/MoodLogSettingsInput" } responses: - '200': + "200": description: Updated content: application/json: - schema: { $ref: '#/components/schemas/UpdatedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + 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': + "200": description: Removed content: application/json: - schema: { $ref: '#/components/schemas/DeletedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } + schema: { $ref: "#/components/schemas/DeletedEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/settings/ntfy: get: @@ -2264,17 +2302,17 @@ paths: summary: Get ntfy channel settings operationId: getNtfySettings responses: - '200': + "200": description: Config content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/NtfySettings' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/NtfySettings" } + "401": { $ref: "#/components/responses/Unauthorized" } put: tags: [Settings] summary: Update ntfy channel settings @@ -2283,15 +2321,15 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/NtfySettingsInput' } + schema: { $ref: "#/components/schemas/NtfySettingsInput" } responses: - '200': + "200": description: Updated content: application/json: - schema: { $ref: '#/components/schemas/UpdatedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + schema: { $ref: "#/components/schemas/UpdatedEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } /api/settings/ntfy/test: post: @@ -2299,12 +2337,12 @@ paths: summary: Send a test ntfy notification operationId: testNtfy responses: - '200': + "200": description: Test result content: application/json: - schema: { $ref: '#/components/schemas/ApiEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } + schema: { $ref: "#/components/schemas/ApiEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/settings/reminder-thresholds: get: @@ -2312,17 +2350,17 @@ paths: summary: Get reminder lateness thresholds (admin-defined defaults) operationId: getReminderThresholds responses: - '200': + "200": description: Thresholds content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/ReminderThresholds' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/ReminderThresholds" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/settings/telegram: get: @@ -2330,17 +2368,17 @@ paths: summary: Get Telegram channel settings operationId: getTelegramSettings responses: - '200': + "200": description: Config content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/TelegramSettings' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/TelegramSettings" } + "401": { $ref: "#/components/responses/Unauthorized" } put: tags: [Settings] summary: Update Telegram channel settings @@ -2349,15 +2387,15 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/TelegramSettingsInput' } + schema: { $ref: "#/components/schemas/TelegramSettingsInput" } responses: - '200': + "200": description: Updated content: application/json: - schema: { $ref: '#/components/schemas/UpdatedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + schema: { $ref: "#/components/schemas/UpdatedEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } /api/settings/telegram/test: post: @@ -2365,12 +2403,12 @@ paths: summary: Send a test Telegram message operationId: testTelegram responses: - '200': + "200": description: Test result content: application/json: - schema: { $ref: '#/components/schemas/ApiEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } + schema: { $ref: "#/components/schemas/ApiEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } # ─── Withings integration ──────────────────────────────────── /api/withings/connect: @@ -2379,10 +2417,10 @@ paths: summary: Begin Withings OAuth (302 redirect) operationId: withingsConnect responses: - '302': + "302": description: Redirect to Withings - '400': { $ref: '#/components/responses/BadRequest' } - '401': { $ref: '#/components/responses/Unauthorized' } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/withings/callback: get: @@ -2397,7 +2435,7 @@ paths: name: state schema: { type: string } responses: - '302': + "302": description: Redirect back to /settings with status /api/withings/disconnect: @@ -2406,21 +2444,21 @@ paths: summary: Disconnect Withings operationId: withingsDisconnect responses: - '200': + "200": description: Disconnected content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: disconnected: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } - '404': { $ref: '#/components/responses/NotFound' } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/NotFound" } /api/withings/credentials: get: @@ -2428,20 +2466,20 @@ paths: summary: Whether Withings client credentials are stored operationId: getWithingsCredentialsStatus responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: hasCredentials: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } + "401": { $ref: "#/components/responses/Unauthorized" } put: tags: [Integrations] summary: Save Withings client ID + secret (encrypted) @@ -2450,26 +2488,26 @@ paths: required: true content: application/json: - schema: { $ref: '#/components/schemas/WithingsCredentialsInput' } + schema: { $ref: "#/components/schemas/WithingsCredentialsInput" } responses: - '200': + "200": description: Saved content: application/json: - schema: { $ref: '#/components/schemas/UpdatedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + 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': + "200": description: Deleted content: application/json: - schema: { $ref: '#/components/schemas/DeletedEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } + schema: { $ref: "#/components/schemas/DeletedEnvelope" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/withings/status: get: @@ -2477,17 +2515,17 @@ paths: summary: Withings connection status operationId: withingsStatus responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/WithingsStatus' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/WithingsStatus" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/withings/sync: post: @@ -2503,13 +2541,13 @@ paths: properties: fullSync: { type: boolean, default: false } responses: - '200': + "200": description: Sync result content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -2517,7 +2555,7 @@ paths: properties: imported: { type: integer } fullSync: { type: boolean } - '401': { $ref: '#/components/responses/Unauthorized' } + "401": { $ref: "#/components/responses/Unauthorized" } /api/withings/webhook: post: @@ -2529,9 +2567,9 @@ paths: - in: query name: secret schema: { type: string } - description: 'Shared secret for webhook authentication.' + description: "Shared secret for webhook authentication." responses: - '200': + "200": description: Acknowledged content: application/json: @@ -2539,9 +2577,9 @@ paths: type: object properties: status: { type: string } - '401': + "401": description: Unauthorized - '429': + "429": description: Rate limited get: tags: [Webhooks] @@ -2553,7 +2591,7 @@ paths: name: secret schema: { type: string } responses: - '200': + "200": description: OK content: application/json: @@ -2561,7 +2599,7 @@ paths: type: object properties: status: { type: string } - '401': + "401": description: Unauthorized head: tags: [Webhooks] @@ -2573,9 +2611,9 @@ paths: name: secret schema: { type: string } responses: - '200': + "200": description: OK - '401': + "401": description: Unauthorized # ─── moodLog integration ───────────────────────────────────── @@ -2585,17 +2623,17 @@ paths: summary: moodLog connection status operationId: moodLogStatus responses: - '200': + "200": description: Status content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MoodLogStatus' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/MoodLogStatus" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/integrations/moodlog/sync: post: @@ -2611,21 +2649,21 @@ paths: properties: fullSync: { type: boolean, default: false } responses: - '200': + "200": description: Sync result content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: imported: { type: integer } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } /api/integrations/moodlog/webhook: post: @@ -2646,12 +2684,12 @@ paths: type: object additionalProperties: true responses: - '200': + "200": description: Acknowledged - '400': { $ref: '#/components/responses/BadRequest' } - '401': { $ref: '#/components/responses/Unauthorized' } - '403': { $ref: '#/components/responses/Forbidden' } - '429': { $ref: '#/components/responses/RateLimited' } + "400": { $ref: "#/components/responses/BadRequest" } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "429": { $ref: "#/components/responses/RateLimited" } /api/telegram/webhook: post: @@ -2667,7 +2705,7 @@ paths: type: object additionalProperties: true responses: - '200': + "200": description: Acknowledged get: tags: [Webhooks] @@ -2675,7 +2713,7 @@ paths: operationId: telegramWebhookGet security: [] responses: - '200': + "200": description: OK # ─── Public monitoring / proxies ───────────────────────────── @@ -2697,7 +2735,7 @@ paths: type: object additionalProperties: true responses: - '204': + "204": description: Accepted (no body) /api/monitoring/glitchtip: @@ -2714,7 +2752,7 @@ paths: type: object additionalProperties: true responses: - '204': + "204": description: Accepted /api/monitoring/settings: @@ -2724,16 +2762,19 @@ paths: operationId: getMonitoringSettings security: [] responses: - '200': + "200": description: Public settings content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/PublicMonitoringSettings' } + data: + { + $ref: "#/components/schemas/PublicMonitoringSettings", + } /api/monitoring/umami-script: get: @@ -2742,7 +2783,7 @@ paths: operationId: getUmamiScript security: [] responses: - '200': + "200": description: Tracker script content: application/javascript: @@ -2762,11 +2803,11 @@ paths: type: object additionalProperties: true responses: - '200': + "200": description: Forwarded - '204': + "204": description: No-op when umami is disabled - '413': + "413": description: Payload too large # ─── Admin (internal) ──────────────────────────────────────── @@ -2775,17 +2816,25 @@ paths: tags: [Admin] security: - sessionCookie: [] - summary: 'Internal: get admin-managed AI fallback settings' + 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' } + "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' + summary: "Internal: update admin-managed AI fallback settings" operationId: adminUpdateAiSettings requestBody: required: true @@ -2793,61 +2842,93 @@ paths: 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' } + "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' + 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' } + - $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' + 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' } + "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' + 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' } + - $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' + - $ref: "#/components/parameters/IdPathParam" patch: tags: [Admin] security: - sessionCookie: [] - summary: 'Internal: update feedback status / admin note' + summary: "Internal: update feedback status / admin note" operationId: adminUpdateFeedback requestBody: required: true @@ -2855,101 +2936,165 @@ paths: 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' } + "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' + 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' } + "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' + - $ref: "#/components/parameters/IdPathParam" post: tags: [Admin] security: - sessionCookie: [] - summary: 'Internal: escalate feedback to a GitHub issue' + 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' } + "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' + 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' } + "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' + 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' } + "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' + 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' } + "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' + 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' } + "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' + 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' } + "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' + summary: "Internal: update app-wide settings" operationId: adminUpdateSettings requestBody: required: true @@ -2957,72 +3102,112 @@ paths: 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' } + "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' + 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' } + "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)' + 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' } + "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' + 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' } + "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' + - $ref: "#/components/parameters/IdPathParam" put: tags: [Admin] security: - sessionCookie: [] - summary: 'Internal: update a user (username/email/role)' + 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' } + 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' + - $ref: "#/components/parameters/IdPathParam" post: tags: [Admin] security: @@ -3030,17 +3215,270 @@ paths: 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' } + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "404": { $ref: "#/components/responses/NotFound" } + + /api/admin/status-overview: + get: + tags: [Admin] + security: + - sessionCookie: [] + summary: "Internal: aggregator for the 6-card status grid (v1.4)" + description: | + Returns the precomputed status payload the admin top strip + renders — users / integrations / monitoring / backups / + maintenance / audit-log summaries with severity already + resolved server-side. Single batched query, no N+1. + operationId: adminStatusOverview + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + + /api/admin/backup/test: + post: + tags: [Admin] + security: + - sessionCookie: [] + summary: "Internal: probe the configured S3-compatible backup target (v1.4)" + description: | + Round-trips a small object against the bucket using the + configured `BACKUP_S3_*` credentials. Returns latency and the + sanitised endpoint host on success; never echoes the access key. + operationId: adminBackupTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "403": { $ref: "#/components/responses/Forbidden" } + "422": { $ref: "#/components/responses/Unprocessable" } + + # ─── Public + v1.4 additions ───────────────────────────────────────── + + /api/version: + get: + tags: [Public] + summary: "Public: running version + build SHA + license" + description: | + Public, unauthenticated. Returns + `{ version, buildSha, builtAt, license, repository, changelog, docs }`. + Used by the docker container healthcheck and the Settings → + About surface's "Check for updates" button. + operationId: getVersion + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + + /api/integrations/withings/test: + post: + tags: [Withings] + security: + - sessionCookie: [] + summary: "Probe the saved Withings OAuth connection (v1.4)" + operationId: withingsTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/integrations/moodlog/test: + post: + tags: [Moodlog] + security: + - sessionCookie: [] + summary: "Probe moodLog.app reachability (v1.4)" + description: | + SSRF-guarded HEAD against the configured moodLog endpoint. + Returns `{ ok, latency_ms, errorCode? }` so the UI can + localise the failure message. + operationId: moodlogTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/notifications/web-push/test: + post: + tags: [Notifications] + security: + - sessionCookie: [] + summary: "Send a test Web Push to the current user (v1.4)" + operationId: webPushTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/monitoring/glitchtip/test: + post: + tags: [Monitoring] + security: + - sessionCookie: [] + summary: "Probe the Glitchtip ingest endpoint (v1.4)" + operationId: glitchtipTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/monitoring/umami/test: + post: + tags: [Monitoring] + security: + - sessionCookie: [] + summary: "Verify the Umami script + website ID resolve (v1.4)" + operationId: umamiTest + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/auth/refresh: + post: + tags: [Auth] + summary: "Native client: refresh-token rotation (v1.4)" + description: | + Exchanges a refresh token (`hlr_<64hex>`) for a fresh 24h + access token and a new refresh token. Cookie-session browsers + do not use this. Reuse-detection: presenting the same refresh + token a second time revokes every refresh token for the user. + operationId: authRefresh + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [refreshToken] + properties: + refreshToken: { type: string, pattern: "^hlr_[A-Fa-f0-9]{64}$" } + revoke: + type: boolean + default: false + description: "When true, revokes the refresh row + paired access token instead of rotating." + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } + "429": { $ref: "#/components/responses/RateLimited" } + + /api/auth/refresh/revoke: + post: + tags: [Auth] + summary: "Native client: revoke a refresh token + paired access token (v1.4)" + operationId: authRefreshRevoke + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [refreshToken] + properties: + refreshToken: { type: string, pattern: "^hlr_[A-Fa-f0-9]{64}$" } + responses: + "200": + { + description: OK, + content: + { + application/json: + { schema: { $ref: "#/components/schemas/ApiEnvelope" } }, + }, + } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } # ─── iOS adapter endpoints (v1.3) ──────────────────────────────────── /api/dashboard/summary: get: tags: [Dashboard] - summary: 'iOS: aggregated dashboard summary' + summary: "iOS: aggregated dashboard summary" description: | Single-call aggregate for the iOS DashboardSummary view. Combines greeting, current/longest activity-day streak, today's medication @@ -3048,28 +3486,28 @@ paths: 7-day sparkline + trend. operationId: getDashboardSummary responses: - '200': + "200": description: Dashboard summary content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/DashboardSummary' } - '401': { $ref: '#/components/responses/Unauthorized' } + 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' + summary: "iOS: time-series + summary stats for a metric kind" operationId: getMeasurementSeries parameters: - in: query name: kind required: true - schema: { $ref: '#/components/schemas/MetricKind' } + schema: { $ref: "#/components/schemas/MetricKind" } - in: query name: days schema: @@ -3078,131 +3516,131 @@ paths: maximum: 365 default: 30 responses: - '200': + "200": description: Series payload content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/MeasurementSeries' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + 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' + summary: "iOS: HealthKit per-metric direction config" operationId: getHealthKitConfig responses: - '200': + "200": description: HealthKit config content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/HealthKitConfig' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/HealthKitConfig" } + "401": { $ref: "#/components/responses/Unauthorized" } patch: tags: [Integrations] - summary: 'iOS: update HealthKit per-metric direction config' + summary: "iOS: update HealthKit per-metric direction config" operationId: updateHealthKitConfig requestBody: required: true content: application/json: - schema: { $ref: '#/components/schemas/HealthKitConfigPatch' } + schema: { $ref: "#/components/schemas/HealthKitConfigPatch" } responses: - '200': + "200": description: Updated config content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/HealthKitConfig' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + 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' + summary: "iOS: flattened user profile" operationId: getUserProfile responses: - '200': + "200": description: User profile content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: - data: { $ref: '#/components/schemas/IosUserProfile' } - '401': { $ref: '#/components/responses/Unauthorized' } + data: { $ref: "#/components/schemas/IosUserProfile" } + "401": { $ref: "#/components/responses/Unauthorized" } patch: tags: [Auth] - summary: 'iOS: update profile fields' + summary: "iOS: update profile fields" operationId: updateUserProfile requestBody: required: true content: application/json: - schema: { $ref: '#/components/schemas/IosUserProfilePatch' } + schema: { $ref: "#/components/schemas/IosUserProfilePatch" } responses: - '200': + "200": description: Updated profile content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $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' } + 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' + summary: "iOS: register an APNs device token" operationId: registerDevice requestBody: required: true content: application/json: - schema: { $ref: '#/components/schemas/DeviceRegisterInput' } + schema: { $ref: "#/components/schemas/DeviceRegisterInput" } responses: - '201': + "201": description: Device registered (or token transferred) content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: object properties: id: { type: string } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/Unprocessable" } /api/medications/intake: get: tags: [Medications] - summary: 'iOS: today list or compliance buckets' + summary: "iOS: today list or compliance buckets" operationId: listIntakeAggregator parameters: - in: query @@ -3217,81 +3655,82 @@ paths: maximum: 365 default: 30 responses: - '200': + "200": description: Aggregator payload content: application/json: - schema: { $ref: '#/components/schemas/ApiEnvelope' } - '401': { $ref: '#/components/responses/Unauthorized' } - '422': { $ref: '#/components/responses/Unprocessable' } + 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)' + summary: "iOS: update an intake event by id (taken/skipped/snoozed)" operationId: updateIntakeAggregator requestBody: required: true content: application/json: - schema: { $ref: '#/components/schemas/IntakeUpdateInput' } + schema: { $ref: "#/components/schemas/IntakeUpdateInput" } responses: - '200': + "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' } + 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' + summary: "iOS: rule-based insight cards" operationId: getInsightCards responses: - '200': + "200": description: Insight cards content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: array - items: { $ref: '#/components/schemas/InsightCard' } - '401': { $ref: '#/components/responses/Unauthorized' } + items: { $ref: "#/components/schemas/InsightCard" } + "401": { $ref: "#/components/responses/Unauthorized" } /api/insights/correlations: get: tags: [Insights] - summary: 'iOS: cross-metric correlations (placeholder)' + 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': + "200": description: Correlations content: application/json: schema: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: type: array - items: { $ref: '#/components/schemas/InsightCorrelation' } - '401': { $ref: '#/components/responses/Unauthorized' } + items: + { $ref: "#/components/schemas/InsightCorrelation" } + "401": { $ref: "#/components/responses/Unauthorized" } components: securitySchemes: bearerAuth: type: http scheme: bearer - bearerFormat: 'hlk_' + bearerFormat: "hlk_" description: | Long-lived API token issued via `POST /api/tokens`. Accepted on all user endpoints (interchangeable with the session cookie) and required @@ -3319,7 +3758,7 @@ components: required: true schema: type: string - description: 'CUID identifier.' + description: "CUID identifier." LimitParam: in: query name: limit @@ -3341,49 +3780,49 @@ components: schema: type: string enum: [de, en] - description: 'Override the resolved locale for narrative text.' + description: "Override the resolved locale for narrative text." responses: BadRequest: description: Bad request content: application/json: - schema: { $ref: '#/components/schemas/ApiError' } + schema: { $ref: "#/components/schemas/ApiError" } Unauthorized: description: Authentication required content: application/json: - schema: { $ref: '#/components/schemas/ApiError' } + schema: { $ref: "#/components/schemas/ApiError" } Forbidden: description: Forbidden content: application/json: - schema: { $ref: '#/components/schemas/ApiError' } + schema: { $ref: "#/components/schemas/ApiError" } NotFound: description: Not found content: application/json: - schema: { $ref: '#/components/schemas/ApiError' } + schema: { $ref: "#/components/schemas/ApiError" } Conflict: description: Conflict (e.g. duplicate) content: application/json: - schema: { $ref: '#/components/schemas/ApiError' } + schema: { $ref: "#/components/schemas/ApiError" } Unprocessable: description: Validation failure content: application/json: - schema: { $ref: '#/components/schemas/ApiError' } + schema: { $ref: "#/components/schemas/ApiError" } RateLimited: description: Rate limit exceeded content: application/json: - schema: { $ref: '#/components/schemas/ApiError' } + schema: { $ref: "#/components/schemas/ApiError" } ServerError: description: Internal server error content: application/json: - schema: { $ref: '#/components/schemas/ApiError' } + schema: { $ref: "#/components/schemas/ApiError" } schemas: # ─── Envelope / errors ───────────────────────────────────── @@ -3394,21 +3833,21 @@ components: data: description: Response payload (typed per-endpoint). error: - type: [string, 'null'] + type: [string, "null"] required: [data, error] ApiError: type: object properties: data: - type: 'null' + type: "null" error: type: string required: [data, error] DeletedEnvelope: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -3418,7 +3857,7 @@ components: UpdatedEnvelope: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -3510,7 +3949,7 @@ components: enum: [normal, mild, moderate, severe, info] Trend: - type: [string, 'null'] + type: [string, "null"] enum: [up, down, stable, null] # ─── Auth DTOs ───────────────────────────────────────────── @@ -3522,7 +3961,7 @@ components: type: string minLength: 3 maxLength: 30 - pattern: '^[a-zA-Z0-9_-]+$' + pattern: "^[a-zA-Z0-9_-]+$" password: type: string minLength: 12 @@ -3539,7 +3978,7 @@ components: LoginResponseEnvelope: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -3565,7 +4004,7 @@ components: RegisterResponseEnvelope: allOf: - - $ref: '#/components/schemas/ApiEnvelope' + - $ref: "#/components/schemas/ApiEnvelope" - type: object properties: data: @@ -3576,23 +4015,23 @@ components: properties: id: { type: string } username: { type: string } - email: { type: [string, 'null'] } + email: { type: [string, "null"] } ProfileInput: type: object properties: - email: { type: [string, 'null'], format: email } + email: { type: [string, "null"], format: email } heightCm: - type: [number, 'null'] + type: [number, "null"] minimum: 50 maximum: 300 dateOfBirth: - type: [string, 'null'] + type: [string, "null"] format: date gender: oneOf: - - $ref: '#/components/schemas/Gender' - - type: 'null' + - $ref: "#/components/schemas/Gender" + - type: "null" ChangePasswordInput: type: object @@ -3608,30 +4047,30 @@ components: 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 } + 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' + - $ref: "#/components/schemas/Gender" + - type: "null" timezone: { type: string } required: [id, username, role, timezone] CurrentUser: allOf: - - $ref: '#/components/schemas/User' + - $ref: "#/components/schemas/User" - type: object properties: onboardingCompletedAt: - type: [string, 'null'] + type: [string, "null"] format: date-time gravatarUrl: - type: [string, 'null'] + type: [string, "null"] glucoseUnit: - type: [string, 'null'] - enum: ['mg/dL', 'mmol/L', null] + type: [string, "null"] + enum: ["mg/dL", "mmol/L", null] # ─── Passkey DTOs ────────────────────────────────────────── PasskeyRegisterOptions: @@ -3662,7 +4101,7 @@ components: credential: type: object additionalProperties: true - description: '`RegistrationResponseJSON` from `@simplewebauthn/browser`.' + description: "`RegistrationResponseJSON` from `@simplewebauthn/browser`." required: [challengeId, credential] PasskeyVerifyLoginInput: @@ -3672,7 +4111,7 @@ components: credential: type: object additionalProperties: true - description: '`AuthenticationResponseJSON` from `@simplewebauthn/browser`.' + description: "`AuthenticationResponseJSON` from `@simplewebauthn/browser`." required: [challengeId, credential] PasskeySummary: @@ -3691,33 +4130,44 @@ components: properties: id: { type: string } userId: { type: string } - type: { $ref: '#/components/schemas/MeasurementType' } + type: { $ref: "#/components/schemas/MeasurementType" } value: { type: number } unit: { type: string } - source: { $ref: '#/components/schemas/MeasurementSource' } + source: { $ref: "#/components/schemas/MeasurementSource" } measuredAt: { type: string, format: date-time } - notes: { type: [string, 'null'], maxLength: 25 } - externalId: { type: [string, 'null'] } + notes: { type: [string, "null"], maxLength: 25 } + externalId: { type: [string, "null"] } glucoseContext: oneOf: - - $ref: '#/components/schemas/GlucoseContext' - - type: 'null' + - $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] + required: + [ + id, + userId, + type, + value, + unit, + source, + measuredAt, + createdAt, + updatedAt, + ] CreateMeasurementInput: type: object properties: - type: { $ref: '#/components/schemas/MeasurementType' } + 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' }] + allOf: [{ $ref: "#/components/schemas/MeasurementSource" }] default: MANUAL glucoseContext: - $ref: '#/components/schemas/GlucoseContext' + $ref: "#/components/schemas/GlucoseContext" required: [type, value, measuredAt] UpdateMeasurementInput: @@ -3725,7 +4175,7 @@ components: properties: value: { type: number, minimum: 0, maximum: 500000 } measuredAt: { type: string, format: date-time } - notes: { type: [string, 'null'], maxLength: 25 } + notes: { type: [string, "null"], maxLength: 25 } # ─── Medication DTOs ─────────────────────────────────────── MedicationSchedule: @@ -3740,11 +4190,12 @@ components: 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' } + 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).' + type: [string, "null"] + description: "Serialized recurrence (CSV of 0..6, optional intervalWeeks suffix)." required: [id, medicationId, windowStart, windowEnd] Medication: @@ -3756,23 +4207,33 @@ components: dose: { type: string } active: { type: boolean } notificationsEnabled: { type: boolean } - pausedAt: { type: [string, 'null'], format: date-time } - snoozedUntil: { type: [string, 'null'], format: date-time } + 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] + required: + [ + id, + userId, + name, + dose, + active, + notificationsEnabled, + createdAt, + updatedAt, + ] MedicationWithSchedules: allOf: - - $ref: '#/components/schemas/Medication' + - $ref: "#/components/schemas/Medication" - type: object properties: schedules: type: array - items: { $ref: '#/components/schemas/MedicationSchedule' } - category: { $ref: '#/components/schemas/MedicationCategory' } + items: { $ref: "#/components/schemas/MedicationSchedule" } + category: { $ref: "#/components/schemas/MedicationCategory" } lastTakenAt: - type: [string, 'null'] + type: [string, "null"] format: date-time todayEventCount: type: integer @@ -3798,11 +4259,11 @@ components: properties: name: { type: string, minLength: 1, maxLength: 100 } dose: { type: string, minLength: 1, maxLength: 50 } - category: { $ref: '#/components/schemas/MedicationCategory' } + category: { $ref: "#/components/schemas/MedicationCategory" } schedules: type: array minItems: 1 - items: { $ref: '#/components/schemas/MedicationScheduleInput' } + items: { $ref: "#/components/schemas/MedicationScheduleInput" } required: [name, dose, schedules] UpdateMedicationInput: @@ -3810,12 +4271,12 @@ components: properties: name: { type: string, minLength: 1, maxLength: 100 } dose: { type: string, minLength: 1, maxLength: 50 } - category: { $ref: '#/components/schemas/MedicationCategory' } + category: { $ref: "#/components/schemas/MedicationCategory" } active: { type: boolean } notificationsEnabled: { type: boolean } schedules: type: array - items: { $ref: '#/components/schemas/MedicationScheduleInput' } + items: { $ref: "#/components/schemas/MedicationScheduleInput" } MedicationIntakeEvent: type: object @@ -3824,12 +4285,13 @@ components: userId: { type: string } medicationId: { type: string } scheduledFor: { type: string, format: date-time } - takenAt: { type: [string, 'null'], format: date-time } + takenAt: { type: [string, "null"], format: date-time } skipped: { type: boolean } - source: { $ref: '#/components/schemas/IntakeSource' } - idempotencyKey: { type: [string, 'null'] } + source: { $ref: "#/components/schemas/IntakeSource" } + idempotencyKey: { type: [string, "null"] } createdAt: { type: string, format: date-time } - required: [id, userId, medicationId, scheduledFor, skipped, source, createdAt] + required: + [id, userId, medicationId, scheduledFor, skipped, source, createdAt] CreateIntakeInput: type: object @@ -3842,7 +4304,7 @@ components: UpdateIntakeInput: type: object properties: - takenAt: { type: [string, 'null'], format: date-time } + takenAt: { type: [string, "null"], format: date-time } skipped: { type: boolean } scheduledFor: { type: string, format: date-time } @@ -3891,7 +4353,7 @@ components: ComplianceSummary: type: object properties: - rate: { type: number, description: 'Fraction 0..1' } + rate: { type: number, description: "Fraction 0..1" } taken: { type: integer } skipped: { type: integer } missed: { type: integer } @@ -3900,23 +4362,40 @@ components: MedicationComplianceResponse: type: object properties: - compliance7: { $ref: '#/components/schemas/ComplianceSummary' } - compliance30: { $ref: '#/components/schemas/ComplianceSummary' } + compliance7: { $ref: "#/components/schemas/ComplianceSummary" } + compliance30: { $ref: "#/components/schemas/ComplianceSummary" } dailyCompliance: type: object - additionalProperties: { $ref: '#/components/schemas/DailyComplianceEntry' } + additionalProperties: + { $ref: "#/components/schemas/DailyComplianceEntry" } ReminderPhaseConfig: type: object properties: greenValue: { type: integer, default: 60 } - greenMode: { allOf: [{ $ref: '#/components/schemas/PhaseMode' }], default: MINUTES } + greenMode: + { + allOf: [{ $ref: "#/components/schemas/PhaseMode" }], + default: MINUTES, + } yellowValue: { type: integer, default: 30 } - yellowMode: { allOf: [{ $ref: '#/components/schemas/PhaseMode' }], default: MINUTES } + yellowMode: + { + allOf: [{ $ref: "#/components/schemas/PhaseMode" }], + default: MINUTES, + } orangeValue: { type: integer, default: 0 } - orangeMode: { allOf: [{ $ref: '#/components/schemas/PhaseMode' }], default: MINUTES } + orangeMode: + { + allOf: [{ $ref: "#/components/schemas/PhaseMode" }], + default: MINUTES, + } redValue: { type: integer, default: 240 } - redMode: { allOf: [{ $ref: '#/components/schemas/PhaseMode' }], default: MINUTES } + redMode: + { + allOf: [{ $ref: "#/components/schemas/PhaseMode" }], + default: MINUTES, + } MedicationApiEndpointResponse: type: object @@ -3924,8 +4403,8 @@ components: enabled: { type: boolean } activeTokenCount: { type: integer } token: - type: [string, 'null'] - description: 'Plaintext token, only returned once on creation.' + type: [string, "null"] + description: "Plaintext token, only returned once on creation." created: { type: boolean } revokedTokenCount: { type: integer } @@ -3946,7 +4425,7 @@ components: properties: points: type: array - items: { $ref: '#/components/schemas/IntakeSummaryPoint' } + items: { $ref: "#/components/schemas/IntakeSummaryPoint" } medications: type: array items: { type: string } @@ -3960,7 +4439,7 @@ components: date: type: string description: YYYY-MM-DD - mood: { $ref: '#/components/schemas/MoodLevel' } + mood: { $ref: "#/components/schemas/MoodLevel" } score: type: integer minimum: 1 @@ -3978,7 +4457,7 @@ components: CreateMoodEntryInput: type: object properties: - mood: { $ref: '#/components/schemas/MoodLevel' } + mood: { $ref: "#/components/schemas/MoodLevel" } moodLoggedAt: { type: string, format: date-time } tags: type: array @@ -3989,7 +4468,7 @@ components: UpdateMoodEntryInput: type: object properties: - mood: { $ref: '#/components/schemas/MoodLevel' } + mood: { $ref: "#/components/schemas/MoodLevel" } moodLoggedAt: { type: string, format: date-time } tags: type: array @@ -4016,7 +4495,19 @@ components: properties: id: type: string - enum: [weight, bp, pulse, bodyFat, mood, medications, sleep, steps, glucose, bpInTarget] + 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] @@ -4029,7 +4520,7 @@ components: type: array minItems: 1 maxItems: 20 - items: { $ref: '#/components/schemas/DashboardWidget' } + items: { $ref: "#/components/schemas/DashboardWidget" } required: [version, widgets] # ─── Insights DTOs ───────────────────────────────────────── @@ -4038,26 +4529,26 @@ components: properties: category: { type: string } color: { type: string } - severity: { $ref: '#/components/schemas/InsightSeverity' } + 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'] } + 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'] + type: [object, "null"] properties: slope: { type: number } r2: { type: number } anomalyCount: { type: integer } CorrelationResult: - type: [object, 'null'] + type: [object, "null"] properties: r: { type: number } strength: { type: string } @@ -4066,12 +4557,12 @@ components: Alert: type: object properties: - severity: { $ref: '#/components/schemas/InsightSeverity' } + severity: { $ref: "#/components/schemas/InsightSeverity" } message: { type: string } type: { type: string } BpTargets: - type: [object, 'null'] + type: [object, "null"] properties: sysLow: { type: integer } sysHigh: { type: integer } @@ -4083,19 +4574,19 @@ components: properties: summaries: type: object - additionalProperties: { $ref: '#/components/schemas/SummaryStats' } - bmi: { type: [number, 'null'] } + additionalProperties: { $ref: "#/components/schemas/SummaryStats" } + bmi: { type: [number, "null"] } bmiClassification: oneOf: - - $ref: '#/components/schemas/Classification' - - type: 'null' + - $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' } + - $ref: "#/components/schemas/Classification" + - type: "null" + bpPctInTarget: { type: [integer, "null"] } + bpTargets: { $ref: "#/components/schemas/BpTargets" } + weightBpCorrelation: { $ref: "#/components/schemas/CorrelationResult" } scatterData: type: array items: @@ -4104,7 +4595,7 @@ components: weight: { type: number } sysBP: { type: number } bpMedicationCorrelation: - type: [object, 'null'] + type: [object, "null"] properties: r: { type: number } strength: { type: string } @@ -4119,9 +4610,9 @@ components: sysBP: { type: number } moodSummary: oneOf: - - $ref: '#/components/schemas/SummaryStats' - - type: 'null' - moodBpCorrelation: { $ref: '#/components/schemas/CorrelationResult' } + - $ref: "#/components/schemas/SummaryStats" + - type: "null" + moodBpCorrelation: { $ref: "#/components/schemas/CorrelationResult" } moodBpScatterData: type: array items: @@ -4129,7 +4620,8 @@ components: properties: mood: { type: number } sysBP: { type: number } - moodWeightCorrelation: { $ref: '#/components/schemas/CorrelationResult' } + moodWeightCorrelation: + { $ref: "#/components/schemas/CorrelationResult" } moodWeightScatterData: type: array items: @@ -4137,7 +4629,7 @@ components: properties: mood: { type: number } weight: { type: number } - moodPulseCorrelation: { $ref: '#/components/schemas/CorrelationResult' } + moodPulseCorrelation: { $ref: "#/components/schemas/CorrelationResult" } moodPulseScatterData: type: array items: @@ -4153,7 +4645,7 @@ components: id: { type: string } name: { type: string } dose: { type: string } - category: { $ref: '#/components/schemas/MedicationCategory' } + category: { $ref: "#/components/schemas/MedicationCategory" } compliance7: { type: number } compliance30: { type: number } streak: { type: integer } @@ -4162,7 +4654,7 @@ components: missed7: { type: integer } alerts: type: array - items: { $ref: '#/components/schemas/Alert' } + items: { $ref: "#/components/schemas/Alert" } hasProvider: { type: boolean } dataSpanDays: { type: integer } totalMeasurements: { type: integer } @@ -4172,19 +4664,19 @@ components: properties: type: { type: string } label: { type: string } - current: { type: [number, 'null'] } - average30: { type: [number, 'null'] } - trend: { $ref: '#/components/schemas/Trend' } + current: { type: [number, "null"] } + average30: { type: [number, "null"] } + trend: { $ref: "#/components/schemas/Trend" } unit: { type: string } range: - type: [object, 'null'] + type: [object, "null"] properties: min: { type: number } max: { type: number } classification: oneOf: - - $ref: '#/components/schemas/Classification' - - type: 'null' + - $ref: "#/components/schemas/Classification" + - type: "null" source: { type: string } details: type: object @@ -4203,26 +4695,26 @@ components: properties: targets: type: array - items: { $ref: '#/components/schemas/InsightTargetItem' } + items: { $ref: "#/components/schemas/InsightTargetItem" } bpDiastolic: type: object properties: - current: { type: [number, 'null'] } - average30: { type: [number, 'null'] } + current: { type: [number, "null"] } + average30: { type: [number, "null"] } range: - type: [object, 'null'] + type: [object, "null"] properties: min: { type: number } max: { type: number } profile: type: object properties: - heightCm: { type: [number, 'null'] } - age: { type: [integer, 'null'] } + heightCm: { type: [number, "null"] } + age: { type: [integer, "null"] } gender: oneOf: - - $ref: '#/components/schemas/Gender' - - type: 'null' + - $ref: "#/components/schemas/Gender" + - type: "null" glucoseUnit: { type: string } InsightStatusBundle: @@ -4243,10 +4735,10 @@ components: type: object properties: codexStatus: { type: string, enum: [connected, disconnected] } - codexConnectedAt: { type: [string, 'null'], format: date-time } + codexConnectedAt: { type: [string, "null"], format: date-time } hasAdminKey: { type: boolean } privacyMode: { type: string, enum: [aggregated, raw] } - lastInsightAt: { type: [string, 'null'], format: date-time } + lastInsightAt: { type: [string, "null"], format: date-time } # ─── Achievements ────────────────────────────────────────── Achievement: @@ -4259,7 +4751,7 @@ components: progress: { type: integer } unlocked: { type: boolean } completedAt: - type: [string, 'null'] + type: [string, "null"] format: date-time AchievementsResult: @@ -4267,7 +4759,7 @@ components: properties: achievements: type: array - items: { $ref: '#/components/schemas/Achievement' } + items: { $ref: "#/components/schemas/Achievement" } unlockedCount: { type: integer } totalCount: { type: integer } @@ -4283,13 +4775,13 @@ components: patient: type: object properties: - username: { type: [string, 'null'] } - dateOfBirth: { type: [string, 'null'], format: date-time } + username: { type: [string, "null"] } + dateOfBirth: { type: [string, "null"], format: date-time } gender: oneOf: - - $ref: '#/components/schemas/Gender' - - type: 'null' - heightCm: { type: [number, 'null'] } + - $ref: "#/components/schemas/Gender" + - type: "null" + heightCm: { type: [number, "null"] } measurements: type: object additionalProperties: @@ -4327,7 +4819,7 @@ components: min: { type: number } max: { type: number } glucoseUnit: { type: string } - bmi: { type: [number, 'null'] } + bmi: { type: [number, "null"] } compliance: type: object additionalProperties: @@ -4351,9 +4843,9 @@ components: properties: windowStart: { type: string } windowEnd: { type: string } - label: { type: [string, 'null'] } + label: { type: [string, "null"] } mood: - type: [object, 'null'] + type: [object, "null"] properties: avg: { type: number } min: { type: number } @@ -4369,7 +4861,7 @@ components: properties: heightCm: { type: number, minimum: 50, maximum: 300 } dateOfBirth: { type: string, format: date } - gender: { $ref: '#/components/schemas/Gender' } + gender: { $ref: "#/components/schemas/Gender" } # ─── Health probe ────────────────────────────────────────── HealthStatus: @@ -4390,8 +4882,8 @@ components: permissions: type: array items: { type: string } - lastUsedAt: { type: [string, 'null'], format: date-time } - expiresAt: { type: [string, 'null'], format: date-time } + lastUsedAt: { type: [string, "null"], format: date-time } + expiresAt: { type: [string, "null"], format: date-time } createdAt: { type: string, format: date-time } revoked: { type: boolean } @@ -4407,7 +4899,7 @@ components: type: object properties: id: { type: string } - type: { $ref: '#/components/schemas/NotificationChannelType' } + type: { $ref: "#/components/schemas/NotificationChannelType" } label: { type: string } enabled: { type: boolean } globallyEnabled: { type: boolean } @@ -4424,10 +4916,10 @@ components: properties: channels: type: array - items: { $ref: '#/components/schemas/NotificationChannel' } + items: { $ref: "#/components/schemas/NotificationChannel" } preferences: type: array - items: { $ref: '#/components/schemas/NotificationPreference' } + items: { $ref: "#/components/schemas/NotificationPreference" } eventTypes: type: array items: { type: string } @@ -4458,12 +4950,12 @@ components: properties: provider: oneOf: - - $ref: '#/components/schemas/AiProviderType' - - type: 'null' - model: { type: [string, 'null'] } - baseUrl: { type: [string, 'null'] } + - $ref: "#/components/schemas/AiProviderType" + - type: "null" + model: { type: [string, "null"] } + baseUrl: { type: [string, "null"] } hasAnthropicKey: { type: boolean } - anthropicKeyPreview: { type: [string, 'null'] } + anthropicKeyPreview: { type: [string, "null"] } hasLocalKey: { type: boolean } UpdateUserAiProviderInput: @@ -4471,12 +4963,12 @@ components: 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'] } + - $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 @@ -4493,17 +4985,17 @@ components: properties: range: oneOf: - - $ref: '#/components/schemas/ThresholdRange' - - type: 'null' + - $ref: "#/components/schemas/ThresholdRange" + - type: "null" isOverride: { type: boolean } defaultRange: oneOf: - - $ref: '#/components/schemas/ThresholdRange' - - type: 'null' + - $ref: "#/components/schemas/ThresholdRange" + - type: "null" ThresholdOverrides: type: object - description: 'Map of metric name → `{ min, max }` override.' + description: "Map of metric name → `{ min, max }` override." additionalProperties: type: object properties: @@ -4515,8 +5007,8 @@ components: properties: effective: type: object - additionalProperties: { $ref: '#/components/schemas/EffectiveRange' } - overrides: { $ref: '#/components/schemas/ThresholdOverrides' } + additionalProperties: { $ref: "#/components/schemas/EffectiveRange" } + overrides: { $ref: "#/components/schemas/ThresholdOverrides" } # ─── Analytics ───────────────────────────────────────────── AnalyticsResponse: @@ -4524,18 +5016,18 @@ components: properties: summaries: type: object - additionalProperties: { $ref: '#/components/schemas/SummaryStats' } - bmi: { type: [number, 'null'] } - bpInTargetPct: { type: [integer, 'null'] } + additionalProperties: { $ref: "#/components/schemas/SummaryStats" } + bmi: { type: [number, "null"] } + bpInTargetPct: { type: [integer, "null"] } glucoseByContext: type: object - additionalProperties: { $ref: '#/components/schemas/SummaryStats' } + additionalProperties: { $ref: "#/components/schemas/SummaryStats" } AiTestResult: type: object properties: ok: { type: boolean } - providerType: { $ref: '#/components/schemas/AiProviderType' } + providerType: { $ref: "#/components/schemas/AiProviderType" } model: { type: string } tokensUsed: type: object @@ -4554,7 +5046,7 @@ components: items: type: object properties: - type: { $ref: '#/components/schemas/MeasurementType' } + type: { $ref: "#/components/schemas/MeasurementType" } value: { type: number } unit: { type: string } measuredAt: { type: string, format: date-time } @@ -4568,7 +5060,7 @@ components: type: object properties: date: { type: string } - mood: { $ref: '#/components/schemas/MoodLevel' } + mood: { $ref: "#/components/schemas/MoodLevel" } score: { type: integer, minimum: 1, maximum: 5 } tags: { type: string } loggedAt: { type: string, format: date-time } @@ -4587,9 +5079,9 @@ components: properties: id: { type: string } action: { type: string } - ipAddress: { type: [string, 'null'] } - location: { type: [string, 'null'] } - details: { type: [string, 'null'], description: 'JSON string' } + ipAddress: { type: [string, "null"] } + location: { type: [string, "null"] } + details: { type: [string, "null"], description: "JSON string" } createdAt: { type: string, format: date-time } AuditLogResponse: @@ -4597,8 +5089,8 @@ components: properties: entries: type: array - items: { $ref: '#/components/schemas/AuditLogEntry' } - meta: { $ref: '#/components/schemas/PageMeta' } + items: { $ref: "#/components/schemas/AuditLogEntry" } + meta: { $ref: "#/components/schemas/PageMeta" } # ─── Feedback ────────────────────────────────────────────── FeedbackCategory: @@ -4613,23 +5105,23 @@ components: type: object properties: id: { type: string } - category: { $ref: '#/components/schemas/FeedbackCategory' } + category: { $ref: "#/components/schemas/FeedbackCategory" } subject: { type: string } - status: { $ref: '#/components/schemas/FeedbackStatus' } - adminNote: { type: [string, 'null'] } - gitHubIssueUrl: { type: [string, 'null'], format: uri } + 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' } + category: { $ref: "#/components/schemas/FeedbackCategory" } subject: { type: string } description: { type: string } screenshot: type: string - description: 'Base64-encoded PNG/JPG (≤5 MB).' + description: "Base64-encoded PNG/JPG (≤5 MB)." metadata: type: object additionalProperties: true @@ -4641,7 +5133,7 @@ components: description: { type: string, minLength: 10, maxLength: 5000 } screenshot: type: string - description: '`data:image/(png|jpeg|webp|gif);base64,...` data URL.' + description: "`data:image/(png|jpeg|webp|gif);base64,...` data URL." required: [description] # ─── Settings ────────────────────────────────────────────── @@ -4657,39 +5149,39 @@ components: MoodLogSettingsInput: type: object properties: - url: { type: [string, 'null'], format: uri } - apiKey: { type: [string, 'null'] } + url: { type: [string, "null"], format: uri } + apiKey: { type: [string, "null"] } enabled: { type: boolean } - webhookSecret: { type: [string, 'null'] } + webhookSecret: { type: [string, "null"] } NtfySettings: type: object properties: configured: { type: boolean } enabled: { type: boolean } - topic: { type: [string, 'null'] } - serverUrl: { type: [string, 'null'] } + topic: { type: [string, "null"] } + serverUrl: { type: [string, "null"] } NtfySettingsInput: type: object properties: - topic: { type: [string, 'null'] } - serverUrl: { type: [string, 'null'], format: uri } + topic: { type: [string, "null"] } + serverUrl: { type: [string, "null"], format: uri } enabled: { type: boolean } - accessToken: { type: [string, 'null'] } + accessToken: { type: [string, "null"] } TelegramSettings: type: object properties: configured: { type: boolean } enabled: { type: boolean } - chatId: { type: [string, 'null'] } + chatId: { type: [string, "null"] } TelegramSettingsInput: type: object properties: - botToken: { type: [string, 'null'] } - chatId: { type: [string, 'null'] } + botToken: { type: [string, "null"] } + chatId: { type: [string, "null"] } enabled: { type: boolean } ReminderThresholds: @@ -4711,7 +5203,7 @@ components: properties: connected: { type: boolean } configured: { type: boolean } - lastSyncedAt: { type: [string, 'null'], format: date-time } + lastSyncedAt: { type: [string, "null"], format: date-time } connectedAt: { type: string, format: date-time } tokenExpired: { type: boolean } tokenRefreshFailed: { type: boolean } @@ -4722,19 +5214,19 @@ components: properties: configured: { type: boolean } enabled: { type: boolean } - lastSyncedAt: { type: [string, 'null'], format: date-time } + lastSyncedAt: { type: [string, "null"], format: date-time } entryCount: { type: integer } - webhookSecret: { type: [string, 'null'] } + webhookSecret: { type: [string, "null"] } PublicMonitoringSettings: type: object properties: umamiEnabled: { type: boolean } - umamiScriptUrl: { type: [string, 'null'] } - umamiWebsiteId: { type: [string, 'null'] } + umamiScriptUrl: { type: [string, "null"] } + umamiWebsiteId: { type: [string, "null"] } glitchtipEnabled: { type: boolean } - glitchtipDsn: { type: [string, 'null'] } - glitchtipEnvironment: { type: [string, 'null'] } + glitchtipDsn: { type: [string, "null"] } + glitchtipEnvironment: { type: [string, "null"] } # ─── iOS adapter DTOs (v1.3) ────────────────────────────── # Used by /api/dashboard/summary (`MetricCard.kind`) and the @@ -4786,10 +5278,10 @@ components: type: object properties: id: { type: string } - kind: { $ref: '#/components/schemas/MetricKind' } + kind: { $ref: "#/components/schemas/MetricKind" } title: { type: string } - latestValue: { type: [number, 'null'] } - secondaryValue: { type: [number, 'null'] } + latestValue: { type: [number, "null"] } + secondaryValue: { type: [number, "null"] } unit: { type: string } trend: type: string @@ -4797,22 +5289,22 @@ components: sparkline: type: array items: { type: number } - updatedAt: { type: [string, 'null'], format: date-time } + 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' } + greeting: { $ref: "#/components/schemas/DashboardSummaryGreeting" } + streak: { $ref: "#/components/schemas/DashboardSummaryStreak" } + compliance: { $ref: "#/components/schemas/DashboardSummaryCompliance" } highlightInsight: oneOf: - - { type: 'null' } - - { $ref: '#/components/schemas/InsightCard' } + - { type: "null" } + - { $ref: "#/components/schemas/InsightCard" } metrics: type: array - items: { $ref: '#/components/schemas/DashboardMetricCard' } + items: { $ref: "#/components/schemas/DashboardMetricCard" } lastUpdated: { type: string, format: date-time } required: [greeting, streak, compliance, metrics, lastUpdated] @@ -4822,7 +5314,7 @@ components: id: { type: string } at: { type: string, format: date-time } value: { type: number } - secondary: { type: [number, 'null'] } + secondary: { type: [number, "null"] } required: [id, at, value] MeasurementSeriesStats: @@ -4838,11 +5330,11 @@ components: MeasurementSeries: type: object properties: - kind: { $ref: '#/components/schemas/MetricKind' } + kind: { $ref: "#/components/schemas/MetricKind" } points: type: array - items: { $ref: '#/components/schemas/MeasurementSeriesPoint' } - stats: { $ref: '#/components/schemas/MeasurementSeriesStats' } + items: { $ref: "#/components/schemas/MeasurementSeriesPoint" } + stats: { $ref: "#/components/schemas/MeasurementSeriesStats" } required: [kind, points, stats] HealthKitDirection: @@ -4854,7 +5346,7 @@ components: properties: id: { type: string } kind: { type: string } - direction: { $ref: '#/components/schemas/HealthKitDirection' } + direction: { $ref: "#/components/schemas/HealthKitDirection" } enabled: { type: boolean } required: [id, kind, direction, enabled] @@ -4863,8 +5355,8 @@ components: properties: entries: type: array - items: { $ref: '#/components/schemas/HealthKitEntry' } - lastSyncedAt: { type: [string, 'null'], format: date-time } + items: { $ref: "#/components/schemas/HealthKitEntry" } + lastSyncedAt: { type: [string, "null"], format: date-time } required: [entries] HealthKitConfigPatch: @@ -4878,7 +5370,7 @@ components: properties: id: { type: string } kind: { type: string } - direction: { $ref: '#/components/schemas/HealthKitDirection' } + direction: { $ref: "#/components/schemas/HealthKitDirection" } enabled: { type: boolean } required: [id, direction] required: [entries] @@ -4887,28 +5379,28 @@ components: 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'] } + 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'] } + 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 } + - { type: "null" } + - { $ref: "#/components/schemas/Gender" } + heightCm: { type: [number, "null"], minimum: 50, maximum: 300 } locale: - type: [string, 'null'] + type: [string, "null"] enum: [de, en, null] timezone: { type: string, minLength: 1, maxLength: 64 } @@ -4938,7 +5430,7 @@ components: properties: id: { type: string } label: { type: string } - actionURL: { type: [string, 'null'] } + actionURL: { type: [string, "null"] } required: [id, label] InsightCard: @@ -4947,14 +5439,15 @@ components: id: { type: string } title: { type: string } summary: { type: string } - body: { type: [string, 'null'] } - severity: { $ref: '#/components/schemas/InsightSeverityIos' } + body: { type: [string, "null"] } + severity: { $ref: "#/components/schemas/InsightSeverityIos" } recommendations: type: array - items: { $ref: '#/components/schemas/InsightRecommendation' } + items: { $ref: "#/components/schemas/InsightRecommendation" } generatedAt: { type: string, format: date-time } provider: { type: string } - required: [id, title, summary, severity, recommendations, generatedAt, provider] + required: + [id, title, summary, severity, recommendations, generatedAt, provider] InsightCorrelation: type: object @@ -4971,5 +5464,5 @@ components: type: object properties: username: { type: string, minLength: 3, maxLength: 30 } - email: { type: [string, 'null'], format: email } - role: { $ref: '#/components/schemas/UserRole' } + email: { type: [string, "null"], format: email } + role: { $ref: "#/components/schemas/UserRole" } diff --git a/docs/migration/v1.3-to-v1.4.md b/docs/migration/v1.3-to-v1.4.md index 3eb2a0f..24b29f9 100644 --- a/docs/migration/v1.3-to-v1.4.md +++ b/docs/migration/v1.3-to-v1.4.md @@ -1,11 +1,11 @@ # Migrating from HealthLog 1.3 to 1.4 -Both 1.3.x and 1.4.0 use Prisma migrations 0001–0024 and the same -PostgreSQL schema. The upgrade is **drop-in for self-hosters running -the GHCR image** — no manual migration step beyond the standard -`docker compose pull && docker compose up -d`. This guide documents -what changes for operators and integrators, in case you're extending -the API. +v1.4.0 is **drop-in for self-hosters running the GHCR image** — the +two new migrations (`0025_refresh_tokens`, `0025_user_locale_drift_fix`) +both apply on container start via `prisma migrate deploy`, and every +new env var in §Environment variables below has a safe default. This +guide documents what changes for operators and integrators, in case +you're extending the API or scaling the deployment. ## TL;DR @@ -14,25 +14,83 @@ docker compose pull docker compose up -d ``` -That's it. No DB migration to apply, no env vars to add, no breaking -API change. +That's it. The migrations are forward-compatible (`IF NOT EXISTS` +guards), no env vars are _required_ to be added, and no API contract +changes shape or status code. ## Database -- Prisma schema unchanged from 1.3.3. Migrations 0001–0024 remain - authoritative. v1.4.0 brings no new migration files. +- **`0025_refresh_tokens`** _(new)_ — adds the `refresh_tokens` table + for the native-client 24h access-token / refresh-token rotation + flow. Browser cookie sessions don't use it. +- **`0025_user_locale_drift_fix`** _(new)_ — `ADD COLUMN IF NOT +EXISTS users.locale TEXT` to backfill an environment that was kept + in sync via `prisma db push` rather than `migrate deploy`. No-op + on installations that were already current. +- Both migrations are idempotent. A rollback to 1.3.3 keeps the + schema usable; the dropped tables/columns are simply unused. ## Environment variables -- **`NEXT_PUBLIC_APP_BUILD_SHA`** *(optional, new)* — the short Git - SHA of the build, surfaced through `/api/version` so the future - About surface can render it. The `docker-publish` workflow can wire - this once the About UI lands. -- **`NEXT_PUBLIC_APP_BUILT_AT`** *(optional, new)* — ISO-8601 build - timestamp, same plumbing. - -Neither is required. Local `pnpm dev` returns `null` for both fields -and the UI falls back to "development" wording. +### About surface + +- **`NEXT_PUBLIC_APP_BUILD_SHA`** _(optional, new)_ — short Git SHA + exposed through `/api/version`, rendered on Settings → About. +- **`NEXT_PUBLIC_APP_BUILT_AT`** _(optional, new)_ — ISO-8601 build + timestamp. + +Both are optional; without them, the About surface renders +"development" wording. + +### Worker / web split + +- **`HEALTHLOG_PROCESS_TYPE`** _(optional, default `all`)_ — set to + `web` to disable the pg-boss worker (jobs queue but don't run), or + `worker` to refuse all HTTP traffic with `503 + X-HealthLog-Process-Type: +worker`. Default `all` keeps the single-container behaviour. + Only relevant if you're scaling HTTP and the worker independently. + +### Encryption-key versioning + +- **`ENCRYPTION_KEYS`** _(optional, new)_ — JSON object mapping + versioned key IDs to 64-char hex keys, e.g. `{"v1": "...", "v2": +"..."}`. When present, ciphertexts are written with the + `.` format and decrypted by looking up the key ID. + Existing 1.3-format ciphertexts (no key prefix) keep decrypting + with `ENCRYPTION_KEY` indefinitely. +- **`ENCRYPTION_ACTIVE_KEY_ID`** _(optional, new)_ — the key ID used + for new writes. Must be a key in `ENCRYPTION_KEYS`. Defaults to + the legacy single-key behaviour if either is unset. +- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback + when `ENCRYPTION_KEYS` is empty. No action needed for existing + installs. +- Rotation: `pnpm tsx scripts/rotate-encryption-key.ts ` + re-encrypts every ciphertext under the new key without downtime. + Walk-through in `docs/ops/encryption-key-rotation.md`. + +### Off-host backup target _(optional)_ + +If unset, the existing local `DataBackup` storage continues to work. + +- **`BACKUP_S3_ENDPOINT`** — S3-compatible endpoint URL. Leave blank + to disable off-host backup. +- **`BACKUP_S3_BUCKET`** — destination bucket name. +- **`BACKUP_S3_ACCESS_KEY`**, **`BACKUP_S3_SECRET_KEY`** — IAM + credentials. Worker only requests `PutObject` + `GetObject` — + never `DeleteObject`. Retention is the bucket's lifecycle policy. +- **`BACKUP_S3_REGION`** _(default `auto`)_. +- **`BACKUP_RETENTION_DAYS`** _(default `30`)_ — surfaced in the + admin UI; the lifecycle rule on your bucket is the actual enforcer. +- **`BACKUP_ENCRYPTION_KEY`** — separate 64-char hex key for the + AES-GCM envelope of each upload. **Keep offline / in a vault.** + Lose this and the off-host backups are unreadable. + +### API token hashing _(unchanged but worth re-confirming)_ + +- **`API_TOKEN_HMAC_KEY`** — 64-char hex string. Used to HMAC + Bearer tokens before storage so a DB leak can't be reversed via + rainbow table. Same key now also keys the new `RefreshToken` row + hashes. **Do not rotate without invalidating every native client.** ## API @@ -41,6 +99,30 @@ and the UI falls back to "development" wording. - **`GET /api/version`** — public, static, returns `{ version, buildSha, builtAt, license, repository, changelog, docs }`. No authentication. +- **`POST /api/integrations/withings/test`** — admin-only probe + against the saved Withings OAuth connection. Returns `{ ok, latency_ms }` + on success. +- **`POST /api/integrations/moodlog/test`** — admin-only HEAD + request against `https://moodlog.app/api/health`. SSRF-guarded. +- **`POST /api/notifications/web-push/test`** — sends a smoke push + notification to the current user's subscribed devices. +- **`POST /api/monitoring/glitchtip/test`** — fires a benign event + at the configured DSN. +- **`POST /api/monitoring/umami/test`** — verifies the script URL + + website ID resolve and respond with the expected payload. +- **`POST /api/admin/backup/test`** — admin-only round-trip against + the configured `BACKUP_S3_*` credentials. +- **`GET /api/admin/status-overview`** — admin-only aggregator + returning the six-card summary the new admin top strip renders in + one batched query. +- **`POST /api/auth/refresh`** — native-client refresh-token + rotation. Cookie-session browsers don't use this. +- **`POST /api/auth/refresh/revoke`** — paired logout-on-device that + flips both the refresh row and the matching `ApiToken` row to + revoked. + +All new admin endpoints are hidden from anonymous traffic by the +proxy and rate-limited. ### Behaviour-only changes @@ -111,21 +193,27 @@ n8n, and Health Connect integrations need no change. 8 000–15 000 in the effective-range resolver) now use the same band, anchored on Saint-Maurice et al., JAMA 2020. -## What's deferred to a future release +## URL changes + +- **Settings has moved to per-section routes.** `/settings#integrations` + → `/settings/integrations`, etc. The legacy `/settings#anchor` URLs + still 308-redirect to the new routes; sidebar deep-links and the + `
` patterns from 1.3 keep working. -The v1.3.3 ecosystem audit identified four architectural rewrites that -are intentionally outside v1.4.0: +## What landed in v1.4 vs v1.4.1 -- Settings page split into per-section routes (`/settings/[section]`). -- Admin page redesign with status-first card grid. -- AI insights rework with severity-coloured key-findings hero and - dynamic chart inclusion via an allowlist token map. -- Multi-tenant prep (off-host backups, encryption-key versioning, - worker/web container split, native-Bearer User-Agent gating). -- Postgres-backed integration test suite (Testcontainers). +The v1.3.3 ecosystem audit identified architectural rewrites tracked +through v1.4. Status as of the v1.4.1 cycle: -These ride a future release; the v1.4 marathon closed every CRIT and -HIGH the audit identified except those four. +| Bucket | Status | +| -------------------------------------------------------------------------------------- | ------------------- | +| Settings page split into `/settings/[section]` | ✓ shipped in v1.4.0 | +| Admin status-first card grid + aggregator endpoint | ✓ shipped in v1.4.0 | +| AI insights rework — severity hero + allowlisted inline charts | ✓ shipped in v1.4.0 | +| Multi-tenant prep (off-host backups, key versioning, worker/web split, refresh tokens) | ✓ shipped in v1.4.0 | +| Per-section admin component extraction (inner monolith → one file per panel) | ✓ shipped in v1.4.1 | +| Postgres-backed integration test suite (testcontainers) | ✓ shipped in v1.4.1 | +| Playwright + axe-core E2E foundation | ✓ shipped in v1.4.1 | ## Rollback