diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..59cb8891 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,83 @@ +name: e2e + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Cancel in-progress runs when a new commit lands on the same PR. +concurrency: + group: e2e-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 20 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: healthlog + POSTGRES_PASSWORD: healthlog + POSTGRES_DB: healthlog + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U healthlog" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgresql://healthlog:healthlog@localhost:5432/healthlog?schema=public + ENCRYPTION_KEYS: '{"v1":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}' + ENCRYPTION_ACTIVE_KEY_ID: v1 + API_TOKEN_HMAC_KEY: ci-hmac-key-32-bytes-min-padding-x + AUTH_RP_NAME: HealthLog + AUTH_RP_ID: localhost + AUTH_RP_ORIGIN: http://localhost:3000 + NODE_ENV: production + + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + - run: pnpm db:migrate:deploy + + - name: Cache Playwright browsers + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + playwright-${{ runner.os }}- + + - run: pnpm exec playwright install --with-deps chromium + + - run: pnpm exec next build + + - run: pnpm exec playwright test + env: + E2E_BASE_URL: http://localhost:3000 + + - name: Upload Playwright HTML report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 00000000..2ec52052 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,34 @@ +name: Integration tests + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + integration: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - run: pnpm install --frozen-lockfile + + - run: pnpm db:generate + + - run: pnpm test:integration + env: + # The integration suite boots a Postgres testcontainer and points + # Prisma at it via DATABASE_URL set inside `tests/integration/setup.ts`. + # These env vars satisfy code paths that read them at module load. + API_TOKEN_HMAC_KEY: integration-tests-do-not-use-in-prod + ENCRYPTION_KEY: 0000000000000000000000000000000000000000000000000000000000000000 + SESSION_SECRET: integration-tests diff --git a/.gitignore b/.gitignore index b16ecc06..88abd76e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,11 @@ # testing /coverage +# playwright e2e +/test-results +/playwright-report +/playwright/.cache + # next.js /.next/ /out/ diff --git a/AGENTS.md b/AGENTS.md index d67f4433..50deb524 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/CHANGELOG.md b/CHANGELOG.md index 31586c6d..361c2ef4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,140 @@ # Changelog +## [1.4.2] — 2026-05-08 + +### Fixed — Production deploy hotfixes + +- **Dashboard and Insights pages no longer crash for users without + weight data.** `data?.summaries.WEIGHT` only protected the outer + object — the optional chain stopped one level too early, so + brand-new users (where `summaries` is undefined) hit + `TypeError: undefined is not an object (evaluating 'E?.summaries.WEIGHT')` + on first load. Now `data?.summaries?.WEIGHT`. +- **Container healthcheck uses `127.0.0.1` instead of `localhost`.** + busybox-`wget` in Alpine resolves `localhost` to IPv6 `::1` first, + but Next.js standalone listens on IPv4 `0.0.0.0:3000` only — so the + healthcheck always returned ECONNREFUSED, Docker marked the + container unhealthy, and Traefik returned 503 from the public URL + even though the app was actually running. The Dockerfile-level + `HEALTHCHECK` already used 127.0.0.1; the `docker-compose.yml` + override was the one that drifted to `localhost`. Fixed. + +### Notes + +- The 1.4.1 GHCR image never published cleanly (the docker-publish + workflow reported success in GH Actions but the + `ghcr.io/mbombeck/healthlog:1.4.1` tag returned `manifest unknown` + when Coolify tried to pull). The 1.4.2 release supersedes 1.4.1 + and includes everything 1.4.1 was supposed to ship — the v1.4.1 + source on `main` was always healthy; only the Coolify deploy + surface was broken. + +## [1.4.1] — 2026-05-08 + +### Security + +- **moodLog integration no longer accepts internal-network URLs.** A + user could previously save `http://169.254.169.254/` (cloud-metadata) + or any RFC1918 address as their moodLog instance; the daily sync + worker would then fetch from that target with the user's API key in + the Authorization header. The credentials write path now refuses + non-public hosts, the sync worker re-checks the URL at the actual + fetch site (so legacy rows stored before the guard are also + refused), and the fetch is now `redirect: "manual"` so a public + host cannot 302 to an internal target with the bearer on the + redirect hop. +- **Error reports never echo bearer tokens, Telegram bot tokens, or + query-string secrets.** `WideEventBuilder.setError()` and the + Glitchtip incident path now run every error message and stack + trace through a central `redactSecrets()` filter that scrubs + `Bearer …`, Telegram `bot:` URLs, and `?secret=`, + `?code=`, `?token=`, `?api_key=` query strings. The substitution + is generic `[REDACTED]` so partial entropy is never revealed. + +### Fixed — Citation accuracy + +- **Blood-pressure classification now cites ESH 2023.** The dashboard + tile, the doctor-report PDF, and the inline analytics comments + used to label the band as "ESC/ESH 2018". The numbers haven't + changed (the 2023 ESH update kept the 2018 thresholds), but the + joint authoring did — ESC withdrew from the 2023 document, so the + correct citation is "ESH 2023" alone. +- **Steps target source label is `Saint-Maurice JAMA 2020`** instead + of `WHO`. Every other surface in the app (AI prompts, inline + comments, drift tests) already enforced this attribution; the + insights/targets surface was the last "WHO" label in the tree. + WHO publishes physical-activity _time_, not a step quota. +- **Saint-Maurice "mortality plateau 8000–12000" attribution + softened.** The original JAMA 2020 paper reports continued + dose-response benefit (HR 0.49 at 8k, HR 0.35 at 12k) — not a + plateau. The plateau-shaped finding belongs to Paluch 2022 + _Lancet Public Health_ (PMID 35247352), not Saint-Maurice. The + inline comments and AI prompts now say "continued dose-response + benefit through ~12,000 steps/day" instead. + +### Added — CI safety nets + +- **Postgres-backed integration test suite is now executable.** The + testcontainers infrastructure shipped in 1.4.0; this release wires + the per-test boilerplate through vitest's `globalSetup` so all + four files share one container. `pnpm test:integration` runs ten + tests (rate-limit race, idempotency replay-attack contract, GDPR + Article-17 cascade delete, session create / read / expire) against + a real Postgres in under four seconds. CI runs the suite on every + PR. +- **Playwright + axe-core E2E foundation.** A new `pnpm e2e` runs + five public-surface specs (version endpoint, proxy auth-redirect, + login form autofill hints, DE/EN locale switch, axe-core + accessibility gate) against the production build in CI. Authenticated + flow specs (quick-entry, doctor-report, settings round-trip, + test-buttons, onboarding) ride a follow-up release because they + need a seeded test user; the foundation makes adding them a + one-PR step. + +### Changed — Admin internals + +- **Admin page is now per-section components.** The status-card grid + shipped in 1.4.0 sat on top of a 2,700-line monolith; that monolith + is now 14 focused files in `src/components/admin/` with a 77-line + `src/app/admin/page.tsx` shell that mounts them. Every section + keeps the same DOM, ids, query keys, and i18n keys — no + user-visible change. + +### Fixed + +- **Final ESLint error is gone.** The medications page's "API + endpoint" dialog ran its initial-load fetch through a `useCallback` + paired with `useEffect` and triggered the strict + `react-hooks/set-state-in-effect` rule. Refactored to TanStack + Query — same network calls, no effect, lint count is now zero on + `main`. + +### Documentation + +- **Repo-internal docs synced for v1.4.** README adds the + Multi-tenant ready and Test connection buttons feature blocks, the + API reference table includes the eleven new v1.4 endpoints, and + the model count is corrected to 26 (RefreshToken). AGENTS.md and + CLAUDE.md reflect the per-route `/settings/[section]` layout and + the per-section admin layout. `docs/api/openapi.yaml` documents + the new endpoints (version, refresh, refresh/revoke, + status-overview, backup/test, the five test-connection probes). + `docs/migration/v1.3-to-v1.4.md` corrects the now-wrong "no + migrations" claim and adds full env-var sections for the + worker/web split, encryption-key versioning, and off-host backup + target. + +### Notes + +- No database migration in 1.4.1. +- No environment-variable change required to upgrade. +- No API contract change — every route added in 1.4.0 is still + there; no shapes or status codes flipped. +- The audit pass that drove this release identified five medium + security items and three P0 performance items that warrant + deeper architectural work; those are tracked in + `docs/ops/v141-followup-issues.md` and ride a future release. + ## [1.4.0] — 2026-05-08 ### Added — Foundation, safer ranges, and a faster dashboard @@ -41,7 +176,7 @@ now cites "ESH 2023" with the published source URL. Numbers unchanged. - **"WHO ≥ 8 000 steps/day" hallucination fully removed.** WHO - publishes activity *time* (150–300 min/wk moderate), not a step + publishes activity _time_ (150–300 min/wk moderate), not a step quota. The v1.3.3 fix only landed in `effective-range.ts`; four AI prompt strings and the `getStepsRange()` helper carried the old wording forward. Saint-Maurice et al., JAMA 2020 (mortality plateau @@ -50,7 +185,7 @@ to AASM 2015. - **Body-fat ACE bands corrected and three-way drift resolved.** The classifier used `essential = 6 (M) / 14 (F)` as the floor — but - that's actually ACE's *Athletes* lower bound. Readings below were + that's actually ACE's _Athletes_ lower bound. Readings below were mislabelled "Essential" instead of "Below essential" (a danger band). Six-band classifier now mirrors the ACE table, and the three sites that had three different green-band numbers @@ -221,7 +356,7 @@ via `prisma db push` to dev/prod). Any environment built strictly from `prisma/migrations/` (CI testcontainers, brand-new self-host installs) is now consistent. Migration is `ADD COLUMN IF NOT - EXISTS`, so it's a clean add on a fresh database and a safe no-op +EXISTS`, so it's a clean add on a fresh database and a safe no-op against any environment that was already kept in sync. ### Notes diff --git a/CLAUDE.md b/CLAUDE.md index e84f5a49..926755eb 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 a8fe9615..f392ee83 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 05fa6207..f0715aa1 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 3eb2a0f2..24b29f96 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 diff --git a/docs/ops/v141-followup-issues.md b/docs/ops/v141-followup-issues.md new file mode 100644 index 00000000..ae3f5703 --- /dev/null +++ b/docs/ops/v141-followup-issues.md @@ -0,0 +1,260 @@ +# v1.4.x follow-up issues + +Items identified by the v1.4.1 audit pass that warrant deeper work +than a hardening release should land. Each one carries enough context +that a future contributor can pick it up without re-running the audit. + +## Security — deferred + +### S-FOLLOW-1 — Idempotency-Key concurrent duplicate side-effects (HIGH) + +Audit reference: `~/infra/reports/v14-security-audit.md` H1. + +The `withIdempotency()` wrapper currently does +`findCached → handler → persistCached`. Two requests with the same +`Idempotency-Key` arriving in parallel both miss the SELECT, both +execute the handler (duplicate measurements / intake events), and +one INSERT loses on the unique constraint silently. The CLAUDE.md +contract ("retries with the same Idempotency-Key … replay the +original response — no second side-effect") is therefore violated +whenever a flaky mobile client double-sends. + +**Fix shape**: acquire the slot before the handler runs. +`INSERT … ON CONFLICT DO NOTHING RETURNING id` reserving the +`(userId, key, method, path)` row with a sentinel +`responseStatus = 0`, then UPDATE on completion; concurrent losers +SELECT-loop with backoff for the row to be filled. Or wrap the +whole request in a Postgres advisory lock keyed by hash(userId, key) +via `pg_advisory_xact_lock`. + +Why deferred: needs a careful integration test against the new +testcontainers suite (two concurrent inserts → exactly one handler +invocation) and a public-API contract check that the +sentinel-status row never bleeds into a 2xx replay. Fits a v1.4.2 +PR scope on its own. + +--- + +### S-FOLLOW-2 — Encryption key fallback NODE_ENV gate (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M1. + +`src/lib/crypto.ts:61-74` only requires the 64-hex production key +when `process.env.NODE_ENV === "production"`. A fresh Docker run +that forgets to set `NODE_ENV=production` (raw `node …` invocations, +some operator setups) silently accepts a 32-character key and pads +it deterministically with a SHA-256 of itself, halving the entropy +and making the resulting key recoverable from any leaked partial. + +**Fix shape**: invert the gate to `NODE_ENV !== "development" && +NODE_ENV !== "test"` — fail-closed by default — or require an +explicit `ALLOW_WEAK_DEV_KEYS=1` opt-in. + +Why deferred: needs a migration note for self-hosters whose `.env` +files lack `NODE_ENV` (default Coolify deploys do set it; bespoke +setups may not). Worth a release note + .env.example call-out. + +--- + +### S-FOLLOW-3 — Refresh-token reuse-detection serialisation (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M2. + +`src/lib/auth/refresh-token.ts` reuse-detection is a `findUnique` +followed by a non-transactional `findMany + updateMany`. Concurrent +first-time refreshes can briefly have both clients holding +unrevoked access tokens before the loser's are revoked, and a +parallel reuse attempt doubles the audit-log noise. + +**Fix shape**: wrap the rotation in a single `prisma.$transaction` +with `Serializable` isolation, OR reorder to "claim first, mint +second": `prisma.refreshToken.update({ where: { id, usedAt: null } })` +fails atomically if the row was already consumed; only after the +update succeeds, call `issueAccessAndRefresh`. + +Why deferred: needs an integration test that fires N parallel +refreshes for the same token and asserts exactly one new pair is +issued. The testcontainers suite is the right home; v1.4.2 PR. + +--- + +### S-FOLLOW-4 — moodLog webhook HMAC lookup column (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M3. + +`src/app/api/integrations/moodlog/webhook/route.ts` performs an +O(n) AES-GCM decrypt sweep across all enabled users for every +webhook hit. Side-channels: timing oracle for ordering, DoS +amplification (30/min IP rate-limit × n decrypts). + +**Fix shape**: add a `moodLogWebhookSecretLookupHash` column on +`User` (HMAC-SHA-256 keyed by `API_TOKEN_HMAC_KEY`, populated +alongside the encrypted secret on every write). Webhook does a +single indexed `findUnique` on the lookup hash, bypassing the +candidate iteration. The encryption stays as defence-in-depth. + +Why deferred: schema migration required. Backfill needs a one-off +script that decrypts every existing row and writes the lookup +hash. Owners need to approve the migration window. v1.4.2 or +v1.5. + +--- + +### S-FOLLOW-5 — moodLog `readMoodLogSecret` legacy fallback (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M4. + +If `decrypt()` throws inside `src/lib/moodlog-secret.ts:27-37`, the +function returns the stored value as-is. Intent: legacy plaintext +rows keep working. Side effect: an attacker with read access to +the DB obtains the ciphertext blob and can submit it as the literal +webhook secret. + +**Fix shape**: detect "looks like a v1.4 envelope" +(`/^[A-Za-z0-9_-]{1,32}\..+$/`) and refuse the legacy fallback for +envelope-shaped values. Log a metric so the rotation-on-write +contract can be verified to drain. + +Why deferred: needs a small data-audit run to confirm no production +row has a non-envelope value before the strict check goes live. + +--- + +### S-FOLLOW-6 — Restore script writes decrypted JSON with default permissions (MEDIUM) + +Audit reference: `~/infra/reports/v14-security-audit.md` M5. + +`scripts/restore-backup.ts:73-122` writes the decrypted JSON via +`writeFileSync(out, plaintext, "utf8")` with default mode (0644 +typical). The file contains decrypted PHI for the restored user. + +**Fix shape**: +`writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 })`. +Optional: warn if `out` is in a world-readable directory. + +Why deferred: tiny one-line fix; bundling with the next ops-script +update keeps the PR coherent. + +--- + +## Performance — deferred + +### P-FOLLOW-1 — Recharts top-level import on `/insights` (P0) + +Audit reference: `~/infra/reports/v14-performance-audit.md` P0. + +`src/app/insights/page.tsx:42-50` static-imports +`{ ScatterChart, Scatter, XAxis, … } from "recharts"` even though +`HealthChart` and `MoodChart` 8 lines above are already wrapped in +`dynamic(…, { ssr: false })`. The static import pulls the whole +recharts bundle into the route's first JS payload, defeating the +dynamic split. + +**Fix shape**: extract the BP-mood scatter into +`src/components/charts/bp-mood-scatter.tsx` and import it via +`dynamic(…, { ssr: false })` like the others. + +Why deferred: trivial mechanical change but needs a Lighthouse +before/after to quantify the bundle-size win for the v1.4.2 +release notes. + +--- + +### P-FOLLOW-2 — `/api/insights/targets` unbounded glucose history scan (P0) + +Audit reference: `~/infra/reports/v14-performance-audit.md` P0. + +`src/app/api/insights/targets/route.ts:600-604` pulls every glucose +measurement the user ever logged (no `gte: thirtyDaysAgo`) and +filters in JS. Diabetic users with multi-year history will +materialise tens of thousands of rows on every targets fetch. + +**Fix shape**: split into "latest per context" +(`distinct: ["glucoseContext"], orderBy: { measuredAt: "desc" }`) +and a `measuredAt: { gte: thirtyDaysAgo }` window query, mirroring +the pattern at lines 102-121 for `latestEverByType`. + +Why deferred: needs a per-context test asserting the new query +returns the same payload shape. Pairs nicely with the MoodEntry +index migration (P-FOLLOW-3). + +--- + +### P-FOLLOW-3 — `MoodEntry @@index` is on `(userId, date)` not `(userId, moodLoggedAt)` (P0) + +Audit reference: `~/infra/reports/v14-performance-audit.md` P0. + +`prisma/schema.prisma:438` declares `@@index([userId, date])`, but +active queries sort/filter by `moodLoggedAt`: + +- `src/app/api/insights/targets/route.ts:495` +- `src/app/api/insights/comprehensive/route.ts:52` +- `src/app/api/export/route.ts:87` + +The unique constraint `@@unique([userId, date, moodLoggedAt])` is +not selectable by Postgres for windowed `moodLoggedAt`-range reads. + +**Fix shape**: add `@@index([userId, moodLoggedAt])` (do NOT drop +the existing index — the unique still serves the moodlog webhook +upsert path). New migration `0026_moodentry_moodloggedat_index`. + +Why deferred: schema migration. Owner approval for the migration +window. + +--- + +### P-FOLLOW-4 to -7 — P1 / P2 perf items + +- N+1 count() per medication on admin reminder-check + (`src/app/api/admin/notifications/reminder-check/route.ts:84`). + Replace with `groupBy` on `medicationIntakeEvent`. +- `/api/analytics` 8-parallel full-history scan + (`src/app/api/analytics/route.ts:20-40`). Single `findMany` with + `type: { in: types }` and JS-side group. +- `/api/import` sequential `prisma.measurement.create` per row + (`src/app/api/import/route.ts:77-96`). Replace with + `createMany({ skipDuplicates: true })`. +- `pairByTimestamp` not used in `/api/analytics` BP correlation + (`src/app/api/analytics/route.ts:80-86`). Replace the quadratic + loop with the helper from `@/lib/analytics/correlations`. + +--- + +## Test-quality polish (5 minor items) + +Audit reference: `~/infra/reports/v14-test-theatre-audit.md`. + +- `src/app/api/gamification/__tests__/ios-format.test.ts` L111-112: + upgrade `toBeDefined()` to shape predicates. +- `src/lib/analytics/__tests__/classifications.test.ts` L169-170: + add `toMatch(/60/)` body-content assertion to compliance-alert tests. +- `src/lib/__tests__/idempotency.test.ts` L145: upgrade bare + `toHaveBeenCalled()` to `toHaveBeenCalledWith({ where: …key… })`. +- `src/app/api/telegram/webhook/__tests__/route.test.ts` L457: same + shape — upgrade or drop. + +--- + +## i18n hardcoded strings + +Audit reference: `~/infra/reports/v14-i18n-drift-audit.md`. + +35+ hardcoded strings in `src/components/settings/ai-section.tsx` +need to be moved into `messages/{en,de}.json` keys. The settings +section was extracted in #143 with strings inline as a known +follow-up; this is the cleanup PR. + +Why deferred: low blast-radius (only the AI provider settings page +shows English to a German user; the dashboard, dialogs, and +critical UX paths all flow through `t(…)`). v1.4.x or v1.5. + +--- + +## Notes + +These are the items the v1.4.1 audit identified that the hardening +release deliberately did NOT take. The audit also found 0 hallucinated +medical claims, 0 i18n key-parity gaps, and the test theatre rate +across the entire codebase was low (5 minor patterns). The codebase +is in healthy shape; this list is the punch-list for the next +proactive iteration, not a backlog of bugs we know about and ignore. diff --git a/e2e/a11y.spec.ts b/e2e/a11y.spec.ts new file mode 100644 index 00000000..8e78816b --- /dev/null +++ b/e2e/a11y.spec.ts @@ -0,0 +1,44 @@ +import AxeBuilder from "@axe-core/playwright"; +import { expect, test } from "@playwright/test"; + +/** + * Accessibility regression gate — fails the build if any WCAG 2.1 AA + * violation lands in the `serious` or `critical` bucket on a public + * surface. Authenticated pages are out of scope here (need seeded + * data); they're covered by the per-flow specs that already touch + * those routes. + */ +test.describe("axe-core public surfaces", () => { + for (const path of ["/auth/login"]) { + test(`${path} has no serious or critical a11y violations`, async ({ + page, + }) => { + await page.goto(path); + // Wait briefly for any reduced-motion fades to settle. + await page.waitForLoadState("networkidle"); + + const results = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + + const blocking = results.violations.filter( + (v) => v.impact === "serious" || v.impact === "critical", + ); + + if (blocking.length > 0) { + // Pretty-print so failures are actionable in CI logs. + console.log( + "axe violations:\n" + + blocking + .map( + (v) => + ` - [${v.impact}] ${v.id}: ${v.help}\n ${v.nodes.length} node(s)\n ${v.helpUrl}`, + ) + .join("\n"), + ); + } + + expect(blocking).toHaveLength(0); + }); + } +}); diff --git a/e2e/auth-redirect.spec.ts b/e2e/auth-redirect.spec.ts new file mode 100644 index 00000000..931cb32d --- /dev/null +++ b/e2e/auth-redirect.spec.ts @@ -0,0 +1,34 @@ +import { expect, test } from "@playwright/test"; + +/** + * The proxy at src/proxy.ts is the single gate enforcing auth on every + * non-public path. A regression that accidentally adds a route to + * PUBLIC_PATHS, or breaks the redirect, would expose unauthenticated + * surfaces. Verify a few representative routes round-trip to /auth/login. + */ +test.describe("proxy auth gate", () => { + for (const path of [ + "/", + "/dashboard", + "/medications", + "/admin", + "/insights", + ]) { + test(`${path} redirects to /auth/login when no session`, async ({ + page, + }) => { + const response = await page.goto(path, { waitUntil: "domcontentloaded" }); + expect(response).not.toBeNull(); + // Either the proxy hands back a 307/308, or the navigation lands + // at /auth/login after redirect; both are acceptable proof. + expect(page.url()).toMatch(/\/auth\/login(\?|$)/); + }); + } + + test("public paths are reachable without a session", async ({ page }) => { + // /auth/login is in PUBLIC_PATHS — must be served, not bounced anywhere. + await page.goto("/auth/login"); + expect(page.url()).toMatch(/\/auth\/login(\?|$)/); + await expect(page.locator("body")).toBeVisible(); + }); +}); diff --git a/e2e/locale-switch.spec.ts b/e2e/locale-switch.spec.ts new file mode 100644 index 00000000..d217e164 --- /dev/null +++ b/e2e/locale-switch.spec.ts @@ -0,0 +1,49 @@ +import { expect, test } from "@playwright/test"; + +/** + * Locale-key drift smoke check — switch the cookie between EN and DE + * and assert the login page (the only public surface that's translated) + * never shows raw i18n keys like `auth.welcomeBack`. + * + * Drift catches: a typo'd t("foo.bar") with no key in either locale + * file would make the raw key surface in the DOM. messages/de.json and + * messages/en.json key parity is enforced by unit tests; this is the + * runtime cousin. + */ +test.describe("locale switch", () => { + for (const locale of ["en", "de"] as const) { + test(`renders /auth/login in ${locale} without raw i18n keys`, async ({ + page, + context, + }) => { + await context.addCookies([ + { + name: "healthlog_locale", + value: locale, + url: page.url() || "http://localhost:3000", + }, + ]); + await page.goto("/auth/login"); + + const bodyText = await page.locator("body").innerText(); + + // i18n keys look like `section.subkey` — letters, numbers, + // underscores, dots — and are never wrapped in normal sentences, + // so a regex match anywhere in body text is a sign the lookup + // fell through. + const rawKeyPattern = /\b[a-z]+(?:[A-Z][a-z]+)?\.[a-z][A-Za-z0-9_.]+\b/; + const match = bodyText.match(rawKeyPattern); + + // Whitelist: filenames or version strings can look like keys, + // skip those by requiring at least one camelCase segment. + if (match) { + const candidate = match[0]; + const looksLikeKey = /\.[a-z][A-Z]/.test(candidate); + expect( + looksLikeKey, + `Possible raw i18n key in ${locale}: ${candidate}`, + ).toBe(false); + } + }); + } +}); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts new file mode 100644 index 00000000..ff07c4ee --- /dev/null +++ b/e2e/login.spec.ts @@ -0,0 +1,53 @@ +import { expect, test } from "@playwright/test"; + +/** + * Smoke checks on the login surface — must render, must wire the + * password-manager autofill hints (autoComplete="username" / + * "current-password"), must let the user submit. + * + * These do NOT log in (no DB seed required); the password-flow + * roundtrip is exercised by the integration test in + * tests/integration/auth-flow.test.ts. + */ +test.describe("login page", () => { + test("renders username + password inputs with the right autoComplete", async ({ + page, + }) => { + await page.goto("/auth/login"); + + const username = page.getByLabel(/username|benutzername/i).first(); + await expect(username).toBeVisible(); + await expect(username).toHaveAttribute("autoComplete", /username|email/); + + const password = page.locator('input[type="password"]').first(); + await expect(password).toBeVisible(); + await expect(password).toHaveAttribute("autoComplete", "current-password"); + }); + + test("rejects an obviously-wrong credential pair", async ({ page }) => { + await page.goto("/auth/login"); + + await page + .getByLabel(/username|benutzername/i) + .first() + .fill("nobody-here"); + await page.locator('input[type="password"]').first().fill("not-the-pw"); + + // Intercept the API call so the test does not depend on a real + // backend rejecting the credentials. The login form's error + // surfacing is what we actually want to prove here. + await page.route("**/api/auth/login", (route) => + route.fulfill({ + status: 401, + contentType: "application/json", + body: JSON.stringify({ error: "Invalid credentials" }), + }), + ); + + await page.getByRole("button", { name: /login|anmelden|sign in/i }).click(); + + await expect( + page.getByText(/invalid credentials|ungültig|falsch/i), + ).toBeVisible({ timeout: 5_000 }); + }); +}); diff --git a/e2e/version.spec.ts b/e2e/version.spec.ts new file mode 100644 index 00000000..9dc11edd --- /dev/null +++ b/e2e/version.spec.ts @@ -0,0 +1,30 @@ +import { expect, test } from "@playwright/test"; + +/** + * /api/version is the public, unauthenticated endpoint that drives + * the Settings → About surface and acts as the container's healthcheck + * target. If this breaks, every deployed image's healthcheck fails and + * Coolify pulls the container out of rotation. + */ +test.describe("public version endpoint", () => { + test("returns the running version + license", async ({ request }) => { + const res = await request.get("/api/version"); + expect(res.status()).toBe(200); + + const json = await res.json(); + expect(json.data).toBeDefined(); + expect(typeof json.data.version).toBe("string"); + expect(json.data.version).toMatch(/^\d+\.\d+\.\d+/); + expect(json.data.license).toBeDefined(); + }); + + test("does not require authentication", async ({ request }) => { + // No cookie, no Authorization header — must still respond 200 so + // the docker healthcheck and the in-app "check for updates" button + // both work without a session. + const res = await request.get("/api/version", { + headers: { "X-Client-Type": "anonymous" }, + }); + expect(res.status()).toBe(200); + }); +}); diff --git a/messages/de.json b/messages/de.json index 01f17e00..834c964c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -62,7 +62,7 @@ "colMin": "Min", "colMax": "Max", "colN": "n", - "bpClassificationTitle": "Blutdruck — ESC/ESH-Klassifikation (2018)", + "bpClassificationTitle": "Blutdruck — ESH-Klassifikation (2023)", "avgBp": "Durchschnittlicher Blutdruck", "classification": "Klassifikation", "bmiTitle": "Body-Mass-Index (BMI)", @@ -634,7 +634,7 @@ "inTargetRange": "im Zielbereich", "bpTarget": "Ziel: {range}", "noDobSet": "Geburtsdatum nicht hinterlegt.", - "avg30dEsc": "30T-Durchschnitt · ESC/ESH 2018", + "avg30dEsc": "30T-Durchschnitt · ESH 2023", "whoClassification": "WHO-Klassifikation", "correlations": "Korrelationen", "weightVsBp": "Gewicht vs. Blutdruck (Systolisch)", diff --git a/messages/en.json b/messages/en.json index 3dcea5ee..0b2704c5 100644 --- a/messages/en.json +++ b/messages/en.json @@ -62,7 +62,7 @@ "colMin": "Min", "colMax": "Max", "colN": "n", - "bpClassificationTitle": "Blood pressure — ESC/ESH classification (2018)", + "bpClassificationTitle": "Blood pressure — ESH classification (2023)", "avgBp": "Average blood pressure", "classification": "Classification", "bmiTitle": "Body Mass Index (BMI)", @@ -634,7 +634,7 @@ "inTargetRange": "in target range", "bpTarget": "Target: {range}", "noDobSet": "Date of birth not set.", - "avg30dEsc": "30d average · ESC/ESH 2018", + "avg30dEsc": "30d average · ESH 2023", "whoClassification": "WHO Classification", "correlations": "Correlations", "weightVsBp": "Weight vs. Blood Pressure (Systolic)", diff --git a/package.json b/package.json index e228ff82..49d10b46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.0", + "version": "1.4.2", "private": true, "packageManager": "pnpm@10.31.0", "scripts": { @@ -17,7 +17,10 @@ "format:check": "prettier --check .", "typecheck": "tsc --noEmit", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:integration": "vitest run --config vitest.integration.config.mts", + "e2e": "playwright test", + "e2e:ui": "playwright test --ui" }, "dependencies": { "@aws-sdk/client-s3": "^3.1045.0", @@ -50,7 +53,10 @@ "zxcvbn-typescript": "^5.0.1" }, "devDependencies": { + "@axe-core/playwright": "^4.11.3", + "@playwright/test": "^1.59.1", "@tailwindcss/postcss": "^4", + "@testcontainers/postgresql": "^11.14.0", "@types/node": "^25", "@types/react": "^19", "@types/react-dom": "^19", @@ -64,6 +70,7 @@ "prettier-plugin-tailwindcss": "^0.8.0", "shadcn": "^4.6.0", "tailwindcss": "^4", + "testcontainers": "^11.14.0", "tw-animate-css": "^1.4.0", "typescript": "^6", "vitest": "^4.1.5" diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 00000000..8d0a8c43 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,65 @@ +import { defineConfig, devices } from "@playwright/test"; + +/** + * Playwright configuration for the HealthLog E2E suite. + * + * The suite covers the smoke-level user paths (auth redirect, login + * form, public version endpoint, locale switch, axe-core) without + * needing seeded data — every spec either runs against an unauthed + * surface or uses route interception to stub out the API. Specs that + * need a logged-in user are kept narrow and flagged in their describe + * block; CI runs them against a worker that seeds a deterministic test + * user on startup. + * + * To run locally: `pnpm dlx playwright install --with-deps chromium` + * once, then `pnpm e2e`. + */ +export default defineConfig({ + testDir: "./e2e", + timeout: 30_000, + expect: { timeout: 5_000 }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: process.env.CI ? 2 : undefined, + reporter: process.env.CI + ? [["github"], ["html", { open: "never" }]] + : [["list"], ["html", { open: "never" }]], + + use: { + baseURL: process.env.E2E_BASE_URL ?? "http://localhost:3000", + trace: "retain-on-failure", + screenshot: "only-on-failure", + video: "retain-on-failure", + }, + + projects: [ + { + name: "chromium-desktop", + use: { + ...devices["Desktop Chrome"], + viewport: { width: 1280, height: 720 }, + }, + }, + { + name: "chromium-mobile", + use: { + ...devices["iPhone 13"], + }, + }, + ], + + // Spin up the production build for E2E. `pnpm build` is run + // separately by CI before the suite — locally, set E2E_SKIP_WEB_SERVER=1 + // to point at an already-running dev server. + webServer: process.env.E2E_SKIP_WEB_SERVER + ? undefined + : { + command: "pnpm exec next start --port 3000", + url: "http://localhost:3000/api/version", + timeout: 60_000, + reuseExistingServer: !process.env.CI, + stdout: "ignore", + stderr: "pipe", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1857c9a1..a5af4b31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -52,7 +52,7 @@ importers: version: 1.14.0(react@19.2.5) next: specifier: 16.2.4 - version: 16.2.4(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + version: 16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -93,9 +93,18 @@ importers: specifier: ^5.0.1 version: 5.0.1 devDependencies: + '@axe-core/playwright': + specifier: ^4.11.3 + version: 4.11.3(playwright-core@1.59.1) + '@playwright/test': + specifier: ^1.59.1 + version: 1.59.1 '@tailwindcss/postcss': specifier: ^4 version: 4.2.4 + '@testcontainers/postgresql': + specifier: ^11.14.0 + version: 11.14.0 '@types/node': specifier: ^25 version: 25.6.0 @@ -110,7 +119,7 @@ importers: version: 3.6.4 '@vitejs/plugin-react': specifier: ^5.1.4 - version: 5.1.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)) + version: 5.1.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4)) '@vitest/coverage-v8': specifier: ^4.1.5 version: 4.1.5(vitest@4.1.5) @@ -135,6 +144,9 @@ importers: tailwindcss: specifier: ^4 version: 4.2.4 + testcontainers: + specifier: ^11.14.0 + version: 11.14.0 tw-animate-css: specifier: ^1.4.0 version: 1.4.0 @@ -143,7 +155,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.5 - version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)) + version: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4)) packages: @@ -310,6 +322,11 @@ packages: resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} + '@axe-core/playwright@4.11.3': + resolution: {integrity: sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -455,6 +472,9 @@ packages: resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@1.0.2': resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} @@ -704,6 +724,20 @@ packages: '@floating-ui/utils@0.2.10': resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@grpc/grpc-js@1.14.3': + resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@hexagon/base64@1.1.28': resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} @@ -928,6 +962,10 @@ packages: '@types/node': optional: true + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -944,9 +982,15 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@kwsites/file-exists@1.1.1': + resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} + '@levischuck/tiny-cbor@0.2.11': resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} @@ -1265,6 +1309,15 @@ packages: resolution: {integrity: sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==} engines: {node: '>=20.0.0'} + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@playwright/test@1.59.1': + resolution: {integrity: sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==} + engines: {node: '>=18'} + hasBin: true + '@prisma/adapter-pg@7.8.0': resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} @@ -1328,6 +1381,36 @@ packages: react: ^18.0.0 || ^19.0.0 react-dom: ^18.0.0 || ^19.0.0 + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.0': + resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + + '@protobufjs/fetch@1.1.0': + resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/inquire@1.1.1': + resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.1': + resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==} + '@radix-ui/number@1.1.1': resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==} @@ -2508,6 +2591,9 @@ packages: peerDependencies: react: ^18 || ^19 + '@testcontainers/postgresql@11.14.0': + resolution: {integrity: sha512-wYbJn8GRTj8qfqzfVubxioYWlHJU/ImIjuzPwyy9C5Qfo6g3GLduPZAj+BifvqTZjgT3gd4gFVLCPhBji7dc1w==} + '@ts-morph/common@0.27.0': resolution: {integrity: sha512-Wf29UqxWDpc+i61k3oIOzcUfQt79PIT9y/MWfAGlrkjg6lBC1hwDECLXPVJAhWjiGbfBCxZd65F/LIZF3+jeJQ==} @@ -2559,6 +2645,12 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/docker-modem@3.0.6': + resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==} + + '@types/dockerode@4.0.1': + resolution: {integrity: sha512-cmUpB+dPN955PxBEuXE3f6lKO1hHiIGYJA46IVF3BJpNsZGvtBDcRnlrHYHtOH/B6vtDOyl2kZ2ShAu3mgc27Q==} + '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} @@ -2568,6 +2660,9 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/node@18.19.130': + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} + '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} @@ -2591,6 +2686,15 @@ packages: '@types/set-cookie-parser@2.4.10': resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + '@types/ssh2-streams@0.1.13': + resolution: {integrity: sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA==} + + '@types/ssh2@0.5.52': + resolution: {integrity: sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg==} + + '@types/ssh2@1.15.5': + resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} @@ -2812,6 +2916,10 @@ packages: '@vitest/utils@4.1.5': resolution: {integrity: sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2856,6 +2964,18 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + archiver-utils@5.0.2: + resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} + engines: {node: '>= 14'} + + archiver@7.0.1: + resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} + engines: {node: '>= 14'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -2902,6 +3022,9 @@ packages: asn1.js@5.4.1: resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + asn1js@3.0.7: resolution: {integrity: sha512-uLvq6KJu04qoQM6gvBfKFjlh6Gl0vOKQuR5cJMDHQkmwfMOQeN3F3SHCv9SNYSL+CRoHvOGFfllDlVz03GQjvQ==} engines: {node: '>=12.0.0'} @@ -2924,6 +3047,12 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + async-lock@1.4.1: + resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} + + async@3.2.6: + resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2936,10 +3065,22 @@ packages: resolution: {integrity: sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==} engines: {node: '>=4'} + axe-core@4.11.4: + resolution: {integrity: sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -2947,10 +3088,54 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bare-events@2.8.2: + resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + base64-arraybuffer@1.0.2: resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==} engines: {node: '>= 0.6.0'} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.20: resolution: {integrity: sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==} engines: {node: '>=6.0.0'} @@ -2961,9 +3146,15 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + better-result@2.8.2: resolution: {integrity: sha512-YOf0VSj5nUPI27doTtXF+BBnsiRq3qY7avHqfIWnppxTLGyvkLq1QV2RTxkwoZwJ60ywLfZ0raFF4J/G886i7A==} + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + bn.js@4.12.3: resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} @@ -2980,6 +3171,9 @@ packages: brace-expansion@1.1.14: resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} + brace-expansion@2.1.0: + resolution: {integrity: sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==} + brace-expansion@5.0.5: resolution: {integrity: sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==} engines: {node: 18 || 20 || >=22} @@ -2993,13 +3187,31 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + buffer-crc32@1.0.0: + resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} + engines: {node: '>=8.0.0'} + buffer-equal-constant-time@1.0.1: resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} + byline@5.0.0: + resolution: {integrity: sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q==} + engines: {node: '>=0.10.0'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3058,6 +3270,9 @@ packages: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} @@ -3102,6 +3317,10 @@ packages: resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} engines: {node: '>=20'} + compress-commons@6.0.2: + resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} + engines: {node: '>= 14'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -3134,6 +3353,9 @@ packages: core-js@3.49.0: resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==} + core-util-is@1.0.3: + resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} engines: {node: '>= 0.10'} @@ -3147,6 +3369,19 @@ packages: typescript: optional: true + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + + crc-32@1.2.2: + resolution: {integrity: sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==} + engines: {node: '>=0.8'} + hasBin: true + + crc32-stream@6.0.0: + resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} + engines: {node: '>= 14'} + cron-parser@5.5.0: resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==} engines: {node: '>=18'} @@ -3313,6 +3548,18 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + docker-compose@1.4.2: + resolution: {integrity: sha512-rPHigTKGaEHpkUmfd69QgaOp+Os5vGJwG/Ry8lcr8W/382AmI+z/D7qoa9BybKIkqNppaIbs8RYeHSevdQjWww==} + engines: {node: '>= 6.0.0'} + + docker-modem@5.0.7: + resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} + engines: {node: '>= 8.0'} + + dockerode@4.0.12: + resolution: {integrity: sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==} + engines: {node: '>= 8.0'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -3328,6 +3575,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -3361,6 +3611,9 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.0: resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} engines: {node: '>=10.13.0'} @@ -3562,9 +3815,20 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + events@3.3.0: + resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} + engines: {node: '>=0.8.x'} + eventsource-parser@3.0.8: resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} engines: {node: '>=18.0.0'} @@ -3605,6 +3869,9 @@ packages: fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + fast-glob@3.3.1: resolution: {integrity: sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==} engines: {node: '>=8.6.0'} @@ -3707,10 +3974,18 @@ packages: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@11.3.4: resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==} engines: {node: '>=14.14'} + fsevents@2.3.2: + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -3763,6 +4038,10 @@ packages: get-port-please@3.2.0: resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-port@7.2.0: + resolution: {integrity: sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==} + engines: {node: '>=16'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -3794,6 +4073,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3901,6 +4185,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -4114,6 +4401,9 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} + isarray@1.0.0: + resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} + isarray@2.0.5: resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} @@ -4140,6 +4430,9 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -4228,6 +4521,10 @@ packages: resolution: {integrity: sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==} engines: {node: '>=0.10'} + lazystream@1.0.1: + resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} + engines: {node: '>= 0.6.3'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -4313,9 +4610,15 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + log-symbols@6.0.0: resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} engines: {node: '>=18'} @@ -4327,6 +4630,9 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -4405,9 +4711,29 @@ packages: minimatch@3.1.5: resolution: {integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==} + minimatch@5.1.9: + resolution: {integrity: sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==} + engines: {node: '>=10'} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + mkdirp@3.0.1: + resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} + engines: {node: '>=10'} + hasBin: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4433,6 +4759,9 @@ packages: resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} engines: {node: '>=8.0.0'} + nan@2.26.2: + resolution: {integrity: sha512-0tTvBTYkt3tdGw22nrAy50x7gpbGCCFH3AFcyS5WiUu7Eu4vWlri1woE6qHBSfy11vksDqkiwjOnlR7WV8G1Hw==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -4502,6 +4831,10 @@ packages: resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} engines: {node: '>=20'} + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4594,6 +4927,9 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@2.1.0: resolution: {integrity: sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==} @@ -4635,6 +4971,10 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -4716,6 +5056,16 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + playwright-core@1.59.1: + resolution: {integrity: sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==} + engines: {node: '>=18'} + hasBin: true + + playwright@1.59.1: + resolution: {integrity: sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==} + engines: {node: '>=18'} + hasBin: true + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -4845,6 +5195,13 @@ packages: typescript: optional: true + process-nextick-args@2.0.1: + resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + + process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -4855,10 +5212,21 @@ packages: proper-lockfile@4.1.2: resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + properties-reader@3.0.1: + resolution: {integrity: sha512-WPn+h9RGEExOKdu4bsF4HksG/uzd3cFq3MFtq8PsFeExPse5Ha/VOjQNyHhjboBFwGXGev6muJYTSPAOkROq2g==} + engines: {node: '>=18'} + + protobufjs@7.5.6: + resolution: {integrity: sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -4971,6 +5339,20 @@ packages: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} + readable-stream@2.3.8: + resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==} + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + readdir-glob@1.1.3: + resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdirp@5.0.0: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} @@ -5074,6 +5456,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.1.2: + resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} + safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} @@ -5192,6 +5577,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -5200,6 +5588,13 @@ packages: resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} engines: {node: '>= 0.6'} + ssh-remote-port-forward@1.0.4: + resolution: {integrity: sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ==} + + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} @@ -5228,6 +5623,9 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} + strict-event-emitter@0.5.1: resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} @@ -5235,6 +5633,10 @@ packages: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -5262,6 +5664,12 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.1.1: + resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-object@5.0.0: resolution: {integrity: sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg==} engines: {node: '>=14.16'} @@ -5332,6 +5740,28 @@ packages: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} + tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + + tar-fs@3.1.2: + resolution: {integrity: sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + testcontainers@11.14.0: + resolution: {integrity: sha512-r9pniwv/iwzyHaI7gwAvAm4Y+IvjJg3vBWdjrUCaDMc2AXIr4jKbq7jJO18Mw2ybs73pZy1Aj7p/4RVBGMRWjg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + text-segmentation@1.0.3: resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==} @@ -5360,6 +5790,10 @@ packages: resolution: {integrity: sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==} hasBin: true + tmp@0.2.5: + resolution: {integrity: sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==} + engines: {node: '>=14.14'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -5401,6 +5835,9 @@ packages: tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -5445,9 +5882,16 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unicorn-magic@0.3.0: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} @@ -5506,6 +5950,11 @@ packages: utrie@1.0.2: resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + valibot@1.2.0: resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} peerDependencies: @@ -5654,6 +6103,10 @@ packages: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -5672,6 +6125,11 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yaml@2.8.4: + resolution: {integrity: sha512-ml/JPOj9fOQK8RNnWojA67GbZ0ApXAUlN2UQclwv2eVgTgn7O9gg9o7paZWKMp4g0H3nTLtS9LVzhkpOFIKzog==} + engines: {node: '>= 14.6'} + hasBin: true + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} @@ -5695,6 +6153,10 @@ packages: zeptomatch@2.1.0: resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zip-stream@6.0.1: + resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} + engines: {node: '>= 14'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -6166,6 +6628,11 @@ snapshots: '@aws/lambda-invoke-store@0.2.4': {} + '@axe-core/playwright@4.11.3(playwright-core@1.59.1)': + dependencies: + axe-core: 4.11.4 + playwright-core: 1.59.1 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6364,6 +6831,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@1.0.2': {} '@dotenvx/dotenvx@1.64.0': @@ -6555,6 +7024,25 @@ snapshots: '@floating-ui/utils@0.2.10': {} + '@grpc/grpc-js@1.14.3': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.6 + yargs: 17.7.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.5.6 + yargs: 17.7.2 + '@hexagon/base64@1.1.28': {} '@hono/node-server@1.19.11(hono@4.12.15)': @@ -6705,6 +7193,15 @@ snapshots: optionalDependencies: '@types/node': 25.6.0 + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6724,8 +7221,16 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@kurkle/color@0.3.4': {} + '@kwsites/file-exists@1.1.1': + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@levischuck/tiny-cbor@0.2.11': {} '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': @@ -7031,6 +7536,13 @@ snapshots: tslib: 2.8.1 tsyringe: 4.10.0 + '@pkgjs/parseargs@0.11.0': + optional: true + + '@playwright/test@1.59.1': + dependencies: + playwright: 1.59.1 + '@prisma/adapter-pg@7.8.0': dependencies: '@prisma/driver-adapter-utils': 7.8.0 @@ -7130,6 +7642,29 @@ snapshots: transitivePeerDependencies: - '@types/react-dom' + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.0': {} + + '@protobufjs/fetch@1.1.0': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/inquire': 1.1.1 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/inquire@1.1.1': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.1': {} + '@radix-ui/number@1.1.1': {} '@radix-ui/primitive@1.1.3': {} @@ -8402,6 +8937,15 @@ snapshots: '@tanstack/query-core': 5.100.9 react: 19.2.5 + '@testcontainers/postgresql@11.14.0': + dependencies: + testcontainers: 11.14.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + '@ts-morph/common@0.27.0': dependencies: fast-glob: 3.3.3 @@ -8465,12 +9009,27 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/docker-modem@3.0.6': + dependencies: + '@types/node': 25.6.0 + '@types/ssh2': 1.15.5 + + '@types/dockerode@4.0.1': + dependencies: + '@types/docker-modem': 3.0.6 + '@types/node': 25.6.0 + '@types/ssh2': 1.15.5 + '@types/estree@1.0.8': {} '@types/json-schema@7.0.15': {} '@types/json5@0.0.29': {} + '@types/node@18.19.130': + dependencies: + undici-types: 5.26.5 + '@types/node@25.6.0': dependencies: undici-types: 7.19.2 @@ -8498,6 +9057,19 @@ snapshots: dependencies: '@types/node': 25.6.0 + '@types/ssh2-streams@0.1.13': + dependencies: + '@types/node': 25.6.0 + + '@types/ssh2@0.5.52': + dependencies: + '@types/node': 25.6.0 + '@types/ssh2-streams': 0.1.13 + + '@types/ssh2@1.15.5': + dependencies: + '@types/node': 18.19.130 + '@types/statuses@2.0.6': {} '@types/trusted-types@2.0.7': @@ -8661,7 +9233,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))': + '@vitejs/plugin-react@5.1.4(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) @@ -8669,7 +9241,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.3 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4) transitivePeerDependencies: - supports-color @@ -8685,7 +9257,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)) + vitest: 4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4)) '@vitest/expect@4.1.5': dependencies: @@ -8696,14 +9268,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0))': + '@vitest/mocker@4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4))': dependencies: '@vitest/spy': 4.1.5 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.14.2(@types/node@25.6.0)(typescript@6.0.3) - vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4) '@vitest/pretty-format@4.1.5': dependencies: @@ -8729,6 +9301,10 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -8768,6 +9344,32 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@6.2.3: {} + + archiver-utils@5.0.2: + dependencies: + glob: 10.5.0 + graceful-fs: 4.2.11 + is-stream: 2.0.1 + lazystream: 1.0.1 + lodash: 4.18.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + + archiver@7.0.1: + dependencies: + archiver-utils: 5.0.2 + async: 3.2.6 + buffer-crc32: 1.0.0 + readable-stream: 4.7.0 + readdir-glob: 1.1.3 + tar-stream: 3.2.0 + zip-stream: 6.0.1 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + argparse@2.0.1: {} aria-hidden@1.2.6: @@ -8850,6 +9452,10 @@ snapshots: minimalistic-assert: 1.0.1 safer-buffer: 2.1.2 + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + asn1js@3.0.7: dependencies: pvtsutils: 1.3.6 @@ -8872,6 +9478,10 @@ snapshots: async-function@1.0.0: {} + async-lock@1.4.1: {} + + async@3.2.6: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -8880,21 +9490,69 @@ snapshots: axe-core@4.11.3: {} + axe-core@4.11.4: {} + axobject-query@4.1.0: {} + b4a@1.8.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + bare-events@2.8.2: {} + + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.2) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.0 + base64-arraybuffer@1.0.2: optional: true + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.20: {} baseline-browser-mapping@2.10.27: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + better-result@2.8.2: {} + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + bn.js@4.12.3: {} body-parser@2.2.2: @@ -8923,6 +9581,10 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 + brace-expansion@2.1.0: + dependencies: + balanced-match: 1.0.2 + brace-expansion@5.0.5: dependencies: balanced-match: 4.0.4 @@ -8939,12 +9601,29 @@ snapshots: node-releases: 2.0.38 update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer-crc32@1.0.0: {} + buffer-equal-constant-time@1.0.1: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.7: + optional: true + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 + byline@5.0.0: {} + bytes@3.1.2: {} c12@3.3.4(magicast@0.5.2): @@ -9016,6 +9695,8 @@ snapshots: dependencies: readdirp: 5.0.0 + chownr@1.1.4: {} + class-variance-authority@0.7.1: dependencies: clsx: 2.1.1 @@ -9050,6 +9731,14 @@ snapshots: commander@14.0.3: {} + compress-commons@6.0.2: + dependencies: + crc-32: 1.2.2 + crc32-stream: 6.0.0 + is-stream: 2.0.1 + normalize-path: 3.0.0 + readable-stream: 4.7.0 + concat-map@0.0.1: {} confbox@0.2.4: {} @@ -9069,6 +9758,8 @@ snapshots: core-js@3.49.0: optional: true + core-util-is@1.0.3: {} + cors@2.8.6: dependencies: object-assign: 4.1.1 @@ -9083,6 +9774,19 @@ snapshots: optionalDependencies: typescript: 6.0.3 + cpu-features@0.0.10: + dependencies: + buildcheck: 0.0.7 + nan: 2.26.2 + optional: true + + crc-32@1.2.2: {} + + crc32-stream@6.0.0: + dependencies: + crc-32: 1.2.2 + readable-stream: 4.7.0 + cron-parser@5.5.0: dependencies: luxon: 3.7.2 @@ -9215,6 +9919,31 @@ snapshots: diff@8.0.4: {} + docker-compose@1.4.2: + dependencies: + yaml: 2.8.4 + + docker-modem@5.0.7: + dependencies: + debug: 4.4.3 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@4.0.12: + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.3 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.7 + protobufjs: 7.5.6 + tar-fs: 2.1.4 + uuid: 10.0.0 + transitivePeerDependencies: + - supports-color + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -9232,6 +9961,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + eastasianwidth@0.2.0: {} + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -9262,6 +9993,10 @@ snapshots: encodeurl@2.0.0: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + enhanced-resolve@5.21.0: dependencies: graceful-fs: 4.2.11 @@ -9628,8 +10363,18 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: + dependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - bare-abort-controller + + events@3.3.0: {} + eventsource-parser@3.0.8: {} eventsource@3.0.7: @@ -9711,6 +10456,8 @@ snapshots: fast-deep-equal@3.1.3: {} + fast-fifo@1.3.2: {} + fast-glob@3.3.1: dependencies: '@nodelib/fs.stat': 2.0.5 @@ -9827,12 +10574,17 @@ snapshots: fresh@2.0.0: {} + fs-constants@1.0.0: {} + fs-extra@11.3.4: dependencies: graceful-fs: 4.2.11 jsonfile: 6.2.1 universalify: 2.0.1 + fsevents@2.3.2: + optional: true + fsevents@2.3.3: optional: true @@ -9882,6 +10634,8 @@ snapshots: get-port-please@3.2.0: {} + get-port@7.2.0: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -9914,6 +10668,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + globals@14.0.0: {} globals@16.4.0: {} @@ -10005,6 +10768,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -10186,6 +10951,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isarray@1.0.0: {} + isarray@2.0.5: {} isexe@2.0.0: {} @@ -10214,6 +10981,12 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + jiti@2.6.1: {} jose@6.2.3: {} @@ -10299,6 +11072,10 @@ snapshots: dependencies: language-subtag-registry: 0.3.23 + lazystream@1.0.1: + dependencies: + readable-stream: 2.3.8 + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -10359,8 +11136,12 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.merge@4.6.2: {} + lodash@4.18.1: {} + log-symbols@6.0.0: dependencies: chalk: 5.6.2 @@ -10372,6 +11153,8 @@ snapshots: dependencies: js-tokens: 4.0.0 + lru-cache@10.4.3: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -10437,8 +11220,22 @@ snapshots: dependencies: brace-expansion: 1.1.14 + minimatch@5.1.9: + dependencies: + brace-expansion: 2.1.0 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.0 + minimist@1.2.8: {} + minipass@7.1.3: {} + + mkdirp-classic@0.5.3: {} + + mkdirp@3.0.1: {} + ms@2.1.3: {} msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3): @@ -10484,6 +11281,9 @@ snapshots: dependencies: lru.min: 1.1.4 + nan@2.26.2: + optional: true + nanoid@3.3.11: {} nanoid@3.3.12: {} @@ -10499,7 +11299,7 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) - next@16.2.4(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + next@16.2.4(@babel/core@7.29.0)(@playwright/test@1.59.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@next/env': 16.2.4 '@swc/helpers': 0.5.15 @@ -10518,6 +11318,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.4 '@next/swc-win32-arm64-msvc': 16.2.4 '@next/swc-win32-x64-msvc': 16.2.4 + '@playwright/test': 1.59.1 sharp: 0.34.5 transitivePeerDependencies: - '@babel/core' @@ -10542,6 +11343,8 @@ snapshots: non-error@0.1.0: {} + normalize-path@3.0.0: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 @@ -10661,6 +11464,8 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + pako@2.1.0: {} parent-module@1.0.1: @@ -10690,6 +11495,11 @@ snapshots: path-parse@1.0.7: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + path-to-regexp@6.3.0: {} path-to-regexp@8.4.2: {} @@ -10767,6 +11577,14 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + playwright-core@1.59.1: {} + + playwright@1.59.1: + dependencies: + playwright-core: 1.59.1 + optionalDependencies: + fsevents: 2.3.2 + possible-typed-array-names@1.1.0: {} postcss-selector-parser@7.1.1: @@ -10837,6 +11655,10 @@ snapshots: - react - react-dom + process-nextick-args@2.0.1: {} + + process@0.11.10: {} + prompts@2.4.2: dependencies: kleur: 3.0.3 @@ -10854,11 +11676,38 @@ snapshots: retry: 0.12.0 signal-exit: 3.0.7 + properties-reader@3.0.1: + dependencies: + '@kwsites/file-exists': 1.1.1 + mkdirp: 3.0.1 + transitivePeerDependencies: + - supports-color + + protobufjs@7.5.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.0 + '@protobufjs/fetch': 1.1.0 + '@protobufjs/float': 1.0.2 + '@protobufjs/inquire': 1.1.1 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.1 + '@types/node': 25.6.0 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} pure-rand@6.1.0: {} @@ -11008,6 +11857,34 @@ snapshots: react@19.2.5: {} + readable-stream@2.3.8: + dependencies: + core-util-is: 1.0.3 + inherits: 2.0.4 + isarray: 1.0.0 + process-nextick-args: 2.0.1 + safe-buffer: 5.1.2 + string_decoder: 1.1.1 + util-deprecate: 1.0.2 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + + readdir-glob@1.1.3: + dependencies: + minimatch: 5.1.9 + readdirp@5.0.0: {} recast@0.23.11: @@ -11159,6 +12036,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.1.2: {} + safe-buffer@5.2.1: {} safe-push-apply@1.0.0: @@ -11364,10 +12243,25 @@ snapshots: source-map@0.6.1: {} + split-ca@1.0.1: {} + split2@4.2.0: {} sqlstring@2.3.3: {} + ssh-remote-port-forward@1.0.4: + dependencies: + '@types/ssh2': 0.5.52 + ssh2: 1.17.0 + + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.26.2 + stable-hash@0.0.5: {} stackback@0.0.2: {} @@ -11388,6 +12282,15 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + streamx@2.25.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + strict-event-emitter@0.5.1: {} string-width@4.2.3: @@ -11396,6 +12299,12 @@ snapshots: is-fullwidth-code-point: 3.0.0 strip-ansi: 6.0.1 + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -11452,6 +12361,14 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.1.1: + dependencies: + safe-buffer: 5.1.2 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + stringify-object@5.0.0: dependencies: get-own-enumerable-keys: 1.0.0 @@ -11500,6 +12417,80 @@ snapshots: tapable@2.3.3: {} + tar-fs@2.1.4: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-fs@3.1.2: + dependencies: + pump: 3.0.4 + tar-stream: 3.2.0 + optionalDependencies: + bare-fs: 4.7.1 + bare-path: 3.0.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + tar-stream@3.2.0: + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.1 + fast-fifo: 1.3.2 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + testcontainers@11.14.0: + dependencies: + '@balena/dockerignore': 1.0.2 + '@types/dockerode': 4.0.1 + archiver: 7.0.1 + async-lock: 1.4.1 + byline: 5.0.0 + debug: 4.4.3 + docker-compose: 1.4.2 + dockerode: 4.0.12 + get-port: 7.2.0 + proper-lockfile: 4.1.2 + properties-reader: 3.0.1 + ssh-remote-port-forward: 1.0.4 + tar-fs: 3.1.2 + tmp: 0.2.5 + undici: 7.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + - supports-color + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + text-segmentation@1.0.3: dependencies: utrie: 1.0.2 @@ -11524,6 +12515,8 @@ snapshots: dependencies: tldts-core: 7.0.30 + tmp@0.2.5: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -11566,6 +12559,8 @@ snapshots: tw-animate-css@1.4.0: {} + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -11633,8 +12628,12 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@5.26.5: {} + undici-types@7.19.2: {} + undici@7.25.0: {} + unicorn-magic@0.3.0: {} universalify@2.0.1: {} @@ -11703,6 +12702,8 @@ snapshots: base64-arraybuffer: 1.0.2 optional: true + uuid@10.0.0: {} + valibot@1.2.0(typescript@6.0.3): optionalDependencies: typescript: 6.0.3 @@ -11728,7 +12729,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0): + vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4): dependencies: esbuild: 0.27.7 fdir: 6.5.0(picomatch@4.0.4) @@ -11741,11 +12742,12 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.32.0 + yaml: 2.8.4 - vitest@4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)): + vitest@4.1.5(@types/node@25.6.0)(@vitest/coverage-v8@4.1.5)(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4)): dependencies: '@vitest/expect': 4.1.5 - '@vitest/mocker': 4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)) + '@vitest/mocker': 4.1.5(msw@2.14.2(@types/node@25.6.0)(typescript@6.0.3))(vite@7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4)) '@vitest/pretty-format': 4.1.5 '@vitest/runner': 4.1.5 '@vitest/snapshot': 4.1.5 @@ -11762,7 +12764,7 @@ snapshots: tinyexec: 1.1.1 tinyglobby: 0.2.16 tinyrainbow: 3.1.0 - vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0) + vite: 7.3.1(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.4) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.6.0 @@ -11844,6 +12846,12 @@ snapshots: string-width: 4.2.3 strip-ansi: 6.0.1 + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + wrappy@1.0.2: {} wsl-utils@0.3.1: @@ -11857,6 +12865,8 @@ snapshots: yallist@3.1.1: {} + yaml@2.8.4: {} + yargs-parser@21.1.1: {} yargs@17.7.2: @@ -11882,6 +12892,12 @@ snapshots: grammex: 3.1.12 graphmatch: 1.1.1 + zip-stream@6.0.1: + dependencies: + archiver-utils: 5.0.2 + compress-commons: 6.0.2 + readable-stream: 4.7.0 + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 6f8a8a9d..688dcf15 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,155 +1,24 @@ "use client"; -import { useState } from "react"; import { useAuth } from "@/hooks/use-auth"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Badge } from "@/components/ui/badge"; -import { Switch } from "@/components/ui/switch"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, - AlertDialogTrigger, -} from "@/components/ui/alert-dialog"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "@/components/ui/dialog"; -import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; -import { - Shield, - Users, - Settings, - Loader2, - KeyRound, - Pencil, - AlertTriangle, - Trash2, - Activity, - Database, - Server, - ScrollText, - ChevronDown, - XCircle, - CheckCircle2, - Eye, - EyeOff, - Clock, - Globe, - MessageCircle, - Bell, - BellRing, - Key, - Bug, - Cog, - MessageSquare, - Inbox, - ExternalLink, - GitPullRequest, -} from "lucide-react"; -import { PasswordStrength } from "@/components/ui/password-strength"; -import { formatDate, formatDateTime } from "@/lib/format"; -import { useTranslations, useFormatters } from "@/lib/i18n/context"; -import { toast } from "sonner"; +import { useTranslations } from "@/lib/i18n/context"; +import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section"; +import { BugReportSection } from "@/components/admin/bug-report-section"; +import { DangerZoneSection } from "@/components/admin/danger-zone-section"; +import { FeedbackInboxSection } from "@/components/admin/feedback-inbox-section"; +import { GeneralSettingsSection } from "@/components/admin/general-settings-section"; +import { GlitchtipSection } from "@/components/admin/glitchtip-section"; +import { LoginOverviewSection } from "@/components/admin/login-overview-section"; +import { RemindersSection } from "@/components/admin/reminders-section"; +import { ServicesSection } from "@/components/admin/services-section"; import { StatusCardGrid } from "@/components/admin/status-card-grid"; - -function PasswordInput(props: React.ComponentProps) { - const [visible, setVisible] = useState(false); - return ( -
- - -
- ); -} - -interface AdminUser { - id: string; - username: string; - email: string | null; - role: string; - createdAt: string; - passkeyCount: number; -} - -interface WorkerStatus { - running: boolean; - startedAt: string | null; - lastHeartbeat: string | null; - lastReminderCheck: string | null; - lastWithingsSync: string | null; - lastInsightsRun: string | null; - jobsProcessed: number; - errors: number; -} - -interface SystemStatus { - version: string; - nodeVersion: string; - gitCommit: string; - buildTime: string; - startTime: string; - database: string; - worker: WorkerStatus; - counts: { - users: number; - measurements: number; - medications: number; - intakeEvents: number; - activeTokens: number; - activeSessions: number; - }; - integrations: { - umami: { configured: boolean; enabled: boolean } | null; - glitchtip: { configured: boolean; enabled: boolean } | null; - webPush: { configured: boolean } | null; - bugReport: { configured: boolean } | null; - }; -} - -interface AdminSettings { - registrationEnabled: boolean; - defaultLocale: string; - telegramGlobal: boolean; - ntfyGlobal: boolean; - webPushGlobal: boolean; - webPushVapidPublicKey: string | null; - webPushVapidSubject: string | null; - webPushVapidConfigured: boolean; - apiGlobal: boolean; - umamiEnabled: boolean; - umamiScriptUrl: string | null; - umamiWebsiteId: string | null; - glitchtipEnabled: boolean; - glitchtipDsn: string | null; - glitchtipEnvironment: string | null; - bugReportRepo: string | null; - bugReportConfigured: boolean; - reminderLateMinutes: number; - reminderMissedMinutes: number; -} +import { SystemStatusSection } from "@/components/admin/system-status-section"; +import { UmamiSection } from "@/components/admin/umami-section"; +import { UserManagementSection } from "@/components/admin/user-management-section"; +import { WebPushVapidSection } from "@/components/admin/web-push-vapid-section"; export default function AdminPage() { const { user } = useAuth(); - const queryClient = useQueryClient(); const { t } = useTranslations(); if (!user || user.role !== "ADMIN") return null; @@ -197,7 +66,6 @@ export default function AdminPage() { @@ -207,2496 +75,3 @@ export default function AdminPage() { ); } - -/* ─────────────────────── Shared helpers ─────────────────────── */ - -function useAdminSettings() { - return useQuery({ - queryKey: ["admin", "settings"], - queryFn: async () => { - const res = await fetch("/api/admin/settings"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as AdminSettings; - }, - }); -} - -function useUpdateSettings() { - const queryClient = useQueryClient(); - const { t } = useTranslations(); - return useMutation({ - mutationFn: async (data: Record) => { - const res = await fetch("/api/admin/settings", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - if (!res.ok) { - throw new Error(await getApiErrorMessage(res)); - } - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["admin", "settings"] }); - toast.success(t("common.saved")); - }, - onError: (err) => { - toast.error( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - ); - }, - }); -} - -async function getApiErrorMessage(response: Response): Promise { - const fallback = `HTTP ${response.status}`; - try { - const json = (await response.json()) as { error?: string }; - if (typeof json?.error === "string" && json.error.trim().length > 0) { - return json.error; - } - } catch { - return fallback; - } - return fallback; -} - -/* ─────────────────────── System Status ─────────────────────── */ - -function SystemStatusSection({ id }: { id: string }) { - const { t } = useTranslations(); - const fmt = useFormatters(); - - const { data: status } = useQuery({ - queryKey: ["admin", "status"], - queryFn: async () => { - const res = await fetch("/api/admin/status"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as SystemStatus; - }, - }); - - return ( -
-
- -

{t("admin.systemStatus")}

-
- {status ? ( -
- - - - - - - - {status.worker.lastReminderCheck && ( - - )} - {status.integrations.umami && ( - - )} - {status.integrations.glitchtip && ( - - )} - {status.integrations.webPush && ( - - )} - {status.integrations.bugReport && ( - - )} -
- ) : ( -
- - - {t("admin.loadingStatus")} - -
- )} -
- ); -} - -function StatusItem({ - icon: Icon, - label, - value, - className, -}: { - icon: React.ComponentType<{ className?: string }>; - label: string; - value: string; - className?: string; -}) { - return ( -
-
- - {label} -
-

{value}

-
- ); -} - -/* ─────────────────────── General Settings ─────────────────────── */ - -function GeneralSettingsSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - - return ( -
-
- -

{t("admin.appSettings")}

-
-
- - updateSettings.mutate({ registrationEnabled: checked }) - } - disabled={updateSettings.isPending} - /> - -
-
-

{t("admin.defaultLanguage")}

-

- {t("admin.defaultLanguageDescription")} -

-
- -
-
-
- ); -} - -/* ─────────────────────── Services ─────────────────────── */ - -function ServicesSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - - return ( -
-
- -

{t("admin.servicesGlobal")}

-
-

- {t("admin.servicesGlobalDescription")} -

-
- - updateSettings.mutate({ telegramGlobal: checked }) - } - disabled={updateSettings.isPending} - /> - - updateSettings.mutate({ ntfyGlobal: checked }) - } - disabled={updateSettings.isPending} - /> - - updateSettings.mutate({ webPushGlobal: checked }) - } - disabled={updateSettings.isPending} - /> - - updateSettings.mutate({ apiGlobal: checked }) - } - disabled={updateSettings.isPending} - /> -
-
- ); -} - -/* ─────────────────────── Umami ─────────────────────── */ - -function UmamiSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - const [umamiScriptUrlDraft, setUmamiScriptUrlDraft] = useState( - null, - ); - const [umamiWebsiteIdDraft, setUmamiWebsiteIdDraft] = useState( - null, - ); - - const umamiScriptUrlValue = - umamiScriptUrlDraft ?? settings?.umamiScriptUrl ?? ""; - const umamiWebsiteIdValue = - umamiWebsiteIdDraft ?? settings?.umamiWebsiteId ?? ""; - - const configured = Boolean( - settings?.umamiScriptUrl && settings?.umamiWebsiteId, - ); - - const testUmami = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/admin/monitoring/umami-test", { - method: "POST", - }); - if (!res.ok) { - throw new Error(await getApiErrorMessage(res)); - } - const json = (await res.json()) as { data?: { message?: string } }; - return json.data?.message ?? t("admin.monitoringTestSuccess"); - }, - onSuccess: (message) => { - toast.success(message); - }, - onError: (error) => { - toast.error( - error instanceof Error - ? error.message - : t("admin.monitoringTestFailed"), - ); - }, - }); - - function saveUmamiSettings() { - updateSettings.mutate( - { - umamiScriptUrl: umamiScriptUrlValue, - umamiWebsiteId: umamiWebsiteIdValue, - }, - { - onSuccess: () => { - setUmamiScriptUrlDraft(null); - setUmamiWebsiteIdDraft(null); - }, - }, - ); - } - - return ( -
-
-
- -

{t("admin.umamiTitle")}

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.umamiDescription")} -

- -
- - updateSettings.mutate({ umamiEnabled: checked }) - } - disabled={updateSettings.isPending} - /> -
-
- - setUmamiScriptUrlDraft(event.target.value)} - placeholder={t("admin.umamiScriptUrlPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - setUmamiWebsiteIdDraft(event.target.value)} - placeholder={t("admin.umamiWebsiteIdPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
-
- -
- - -
-
- ); -} - -/* ─────────────────────── GlitchTip ─────────────────────── */ - -function GlitchtipSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - const [glitchtipDsnDraft, setGlitchtipDsnDraft] = useState( - null, - ); - const [glitchtipEnvironmentDraft, setGlitchtipEnvironmentDraft] = useState< - string | null - >(null); - - const glitchtipDsnValue = glitchtipDsnDraft ?? settings?.glitchtipDsn ?? ""; - const glitchtipEnvironmentValue = - glitchtipEnvironmentDraft ?? settings?.glitchtipEnvironment ?? "production"; - - const configured = Boolean(settings?.glitchtipDsn); - - const testGlitchtip = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/admin/monitoring/glitchtip-test", { - method: "POST", - }); - if (!res.ok) { - throw new Error(await getApiErrorMessage(res)); - } - const json = (await res.json()) as { data?: { message?: string } }; - return json.data?.message ?? t("admin.monitoringTestSuccess"); - }, - onSuccess: (message) => { - toast.success(message); - }, - onError: (error) => { - toast.error( - error instanceof Error - ? error.message - : t("admin.monitoringTestFailed"), - ); - }, - }); - - function saveGlitchtipSettings() { - updateSettings.mutate( - { - glitchtipDsn: glitchtipDsnValue, - glitchtipEnvironment: glitchtipEnvironmentValue, - }, - { - onSuccess: () => { - setGlitchtipDsnDraft(null); - setGlitchtipEnvironmentDraft(null); - }, - }, - ); - } - - return ( -
-
-
- -

{t("admin.glitchtipTitle")}

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.glitchtipDescription")} -

- -
- - updateSettings.mutate({ glitchtipEnabled: checked }) - } - disabled={updateSettings.isPending} - /> -
-
- - setGlitchtipDsnDraft(event.target.value)} - placeholder={t("admin.glitchtipDsnPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - - setGlitchtipEnvironmentDraft(event.target.value) - } - placeholder={t("admin.glitchtipEnvironmentPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
-
- -
- - -
-
- ); -} - -/* ─────────────────────── Web Push VAPID ─────────────────────── */ - -function WebPushVapidSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - const [webPushVapidPublicKeyDraft, setWebPushVapidPublicKeyDraft] = useState< - string | null - >(null); - const [webPushVapidPrivateKeyDraft, setWebPushVapidPrivateKeyDraft] = - useState(""); - const [webPushVapidSubjectDraft, setWebPushVapidSubjectDraft] = useState< - string | null - >(null); - - const webPushVapidPublicKeyValue = - webPushVapidPublicKeyDraft ?? settings?.webPushVapidPublicKey ?? ""; - const webPushVapidSubjectValue = - webPushVapidSubjectDraft ?? settings?.webPushVapidSubject ?? ""; - - const configured = settings?.webPushVapidConfigured ?? false; - - function saveWebPushVapidSettings() { - const payload: Record = { - webPushVapidPublicKey: webPushVapidPublicKeyValue, - webPushVapidSubject: webPushVapidSubjectValue, - }; - if (webPushVapidPrivateKeyDraft.trim().length > 0) { - payload.webPushVapidPrivateKey = webPushVapidPrivateKeyDraft.trim(); - } - - updateSettings.mutate(payload, { - onSuccess: () => { - setWebPushVapidPublicKeyDraft(null); - setWebPushVapidPrivateKeyDraft(""); - setWebPushVapidSubjectDraft(null); - }, - }); - } - - return ( -
-
-
- -

- {t("admin.webPushVapidTitle")} -

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.webPushVapidDescription")} -

- -
-
- - - setWebPushVapidPublicKeyDraft(event.target.value) - } - placeholder={t("admin.webPushVapidPublicKeyPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - - setWebPushVapidPrivateKeyDraft(event.target.value) - } - placeholder={t("admin.webPushVapidPrivateKeyPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - - setWebPushVapidSubjectDraft(event.target.value) - } - placeholder={t("admin.webPushVapidSubjectPlaceholder")} - autoComplete="new-password" - spellCheck={false} - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- -
- -
-
- ); -} - -/* ─────────────────────── Bug Report ─────────────────────── */ - -function BugReportSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - const [bugReportRepoDraft, setBugReportRepoDraft] = useState( - null, - ); - const [bugReportTokenDraft, setBugReportTokenDraft] = useState(""); - - const bugReportRepoValue = - bugReportRepoDraft ?? settings?.bugReportRepo ?? ""; - const configured = settings?.bugReportConfigured ?? false; - - function saveBugReportSettings() { - const payload: Record = { - bugReportRepo: bugReportRepoValue, - }; - if (bugReportTokenDraft.trim().length > 0) { - payload.bugReportToken = bugReportTokenDraft.trim(); - } - - updateSettings.mutate(payload, { - onSuccess: () => { - setBugReportRepoDraft(null); - setBugReportTokenDraft(""); - }, - }); - } - - return ( -
-
-
- -

- {t("admin.bugReportGithub")} -

-
-
- {configured && ( - - {t("admin.configured")} - - )} -
-
-

- {t("admin.bugReportGithubDescription")} -

- -
-
- - setBugReportRepoDraft(event.target.value)} - placeholder={t("admin.bugReportRepoPlaceholder")} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - /> -
-
- - setBugReportTokenDraft(event.target.value)} - placeholder={t("admin.bugReportTokenPlaceholder")} - disabled={updateSettings.isPending} - /> -
-
- -
- -
-
- ); -} - -/* ─────────────────────── Reminders ─────────────────────── */ - -function RemindersSection({ id }: { id: string }) { - const { t } = useTranslations(); - const { data: settings } = useAdminSettings(); - const updateSettings = useUpdateSettings(); - const [reminderLateDraft, setReminderLateDraft] = useState( - null, - ); - const [reminderMissedDraft, setReminderMissedDraft] = useState( - null, - ); - - const testNotification = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/admin/notifications/test", { - method: "POST", - }); - if (!res.ok) { - throw new Error(await getApiErrorMessage(res)); - } - const json = (await res.json()) as { - data?: { - message?: string; - results?: Array<{ - channel: string; - success: boolean; - error?: string; - }>; - }; - }; - return json.data; - }, - onSuccess: (data) => { - const hasFailures = data?.results?.some((r) => !r.success); - if (hasFailures) { - toast.error(data?.message ?? t("admin.notificationTestFailed")); - } else { - toast.success(data?.message ?? t("admin.notificationTestSuccess")); - } - }, - onError: (error) => { - toast.error( - error instanceof Error - ? error.message - : t("admin.notificationTestFailed"), - ); - }, - }); - - const reminderCheck = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/admin/notifications/reminder-check", { - method: "POST", - }); - if (!res.ok) { - throw new Error(await getApiErrorMessage(res)); - } - const json = (await res.json()) as { - data?: { - message?: string; - medications?: Array<{ - name: string; - dose: string; - user: string; - localTime: string; - dayOfWeek: string; - notificationsEnabled: boolean; - schedules: Array<{ - window: string; - days: string; - status: string; - label: string; - notificationSent?: boolean; - }>; - eventsToday: number; - }>; - notificationsSent?: number; - }; - }; - return json.data; - }, - onSuccess: (data) => { - toast.success(data?.message ?? t("admin.reminderCheckSuccess")); - }, - onError: (error) => { - toast.error( - error instanceof Error ? error.message : t("admin.reminderCheckFailed"), - ); - }, - }); - - return ( -
-
- -

- {t("admin.medicationReminders")} -

-
-

- {t("admin.medicationRemindersDescription")} -

- -
-
-
- -

- {t("admin.reminderLateMinutesDescription")} -

- setReminderLateDraft(Number(e.target.value))} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - className="w-32" - /> -
-
- -

- {t("admin.reminderMissedMinutesDescription")} -

- setReminderMissedDraft(Number(e.target.value))} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - disabled={updateSettings.isPending} - className="w-32" - /> -
-
-
- -
- - - -
- - {testNotification.data?.results && - testNotification.data.results.length > 0 && ( -
- {testNotification.data.results.map((r, i) => ( -
- {r.success ? ( - - ) : ( - - )} - {r.channel} - {r.error && ( - — {r.error} - )} -
- ))} -
- )} - - {reminderCheck.data?.medications && - reminderCheck.data.medications.length > 0 && ( -
-

- {t("admin.reminderCheckResults")} -

-
- {reminderCheck.data.medications.map((med, i) => ( -
-
- - {med.name} ({med.dose}) - - - {med.notificationsEnabled - ? t("admin.reminderCheckNotifOn") - : t("admin.reminderCheckNotifOff")} - -
-

- {med.user} — {med.dayOfWeek} {med.localTime} —{" "} - {t("admin.reminderCheckEventsToday")}: {med.eventsToday} -

- {med.schedules.map((sched, j) => { - const statusColor = - sched.status === "open" - ? "text-green-400" - : sched.status === "threshold" - ? "text-yellow-400" - : sched.status === "missed" - ? "text-red-400" - : sched.status === "skipped" - ? "text-muted-foreground" - : ""; - return ( -
- - {sched.window} - - - [{sched.days}] - - {sched.label} - {sched.notificationSent && ( - - - {t("admin.reminderCheckNotifSent")} - - )} -
- ); - })} -
- ))} -
-
- )} -
- ); -} - -/* ─────────────────────── Shared UI Components ─────────────────────── */ - -function SettingsToggle({ - label, - description, - icon: Icon, - checked, - onCheckedChange, - disabled, -}: { - label: string; - description: string; - icon?: React.ComponentType<{ className?: string }>; - checked: boolean; - onCheckedChange: (checked: boolean) => void; - disabled: boolean; -}) { - return ( -
-
- {Icon && } -
-

{label}

-

{description}

-
-
- -
- ); -} - -/* ─────────────────────── Login Overview ─────────────────────── */ - -interface AdminAuditEntry { - id: string; - action: string; - ipAddress: string | null; - location: string | null; - details: string | null; - createdAt: string; - user: { id: string; username: string } | null; -} - -function LoginOverviewSection({ id }: { id: string }) { - const { t } = useTranslations(); - const [expanded, setExpanded] = useState(false); - const [filter, setFilter] = useState<"all" | "failed">("all"); - - const AUTH_ACTION_LABELS: Record = { - "auth.register": t("admin.authRegister"), - "auth.login": t("admin.authLogin"), - "auth.login.passkey": t("admin.authLoginPasskey"), - "auth.login.password": t("admin.authLoginPassword"), - "auth.login.failed": t("admin.authLoginFailed"), - "auth.logout": t("admin.authLogout"), - "auth.passkey.register": t("admin.authPasskeyRegister"), - "auth.passkey.delete": t("admin.authPasskeyDelete"), - }; - - const { data, isLoading } = useQuery({ - queryKey: ["admin", "audit-log", filter], - queryFn: async () => { - const res = await fetch(`/api/admin/audit-log?limit=100&filter=auth`); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as { - entries: AdminAuditEntry[]; - meta: { total: number }; - }; - }, - enabled: expanded, - }); - - const entries = - filter === "failed" - ? data?.entries.filter((e) => e.action === "auth.login.failed") - : data?.entries; - - return ( -
-
-
- -

{t("admin.loginOverview")}

-
- -
- - {expanded && ( -
-
- - -
- - {isLoading ? ( -
- -
- ) : !entries?.length ? ( -

- {t("admin.noEntries")} -

- ) : ( -
- - - - - - - - - - - - - {entries.map((entry, i) => { - const isFailed = entry.action === "auth.login.failed"; - return ( - - - - - - - - - ); - })} - -
- {t("admin.status")} - - {t("admin.users")} - - {t("admin.action")} - - {t("admin.ip")} - - {t("admin.location")} - - {t("admin.timestamp")} -
- {isFailed ? ( - - ) : ( - - )} - - {entry.user?.username ?? t("common.unknown")} - - {AUTH_ACTION_LABELS[entry.action] ?? entry.action} - - {entry.ipAddress ?? "—"} - - {entry.location ?? "—"} - - {formatDateTime(entry.createdAt)} -
- {data && data.meta.total > entries.length && ( -

- {t("admin.showingEntries", { - count: entries.length, - total: data.meta.total, - })} -

- )} -
- )} -
- )} -
- ); -} - -/* ─────────────────────── API Token Overview ─────────────────────── */ - -interface ApiTokenInfo { - id: string; - name: string; - permissions: string[]; - lastUsedAt: string | null; - expiresAt: string | null; - createdAt: string; - revoked: boolean; - user: { id: string; username: string }; -} - -function ApiTokenOverviewSection({ id }: { id: string }) { - const { t } = useTranslations(); - const [expanded, setExpanded] = useState(false); - - const { data: tokens, isLoading } = useQuery({ - queryKey: ["admin", "tokens"], - queryFn: async () => { - const res = await fetch("/api/admin/tokens"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as ApiTokenInfo[]; - }, - enabled: expanded, - }); - - return ( -
-
-
- -

{t("admin.apiTokens")}

-
- -
- - {expanded && ( -
- {isLoading ? ( -
- -
- ) : !tokens?.length ? ( -

- {t("admin.noTokens")} -

- ) : ( -
- - - - - - - - - - - - - {tokens.map((token, i) => { - const isExpired = - token.expiresAt && new Date(token.expiresAt) < new Date(); - return ( - - - - - - - - - ); - })} - -
- {t("admin.tokenUser")} - - {t("admin.tokenName")} - - {t("admin.tokenPermissions")} - - {t("admin.tokenStatus")} - - {t("admin.tokenLastUsed")} - - {t("admin.tokenCreated")} -
- {token.user.username} - {token.name} -
- {token.permissions.map((p) => ( - - {p} - - ))} -
-
- {token.revoked ? ( - - {t("settings.tokenRevoked")} - - ) : isExpired ? ( - - {t("settings.tokenExpired")} - - ) : ( - - {t("common.active")} - - )} - - {token.lastUsedAt - ? formatDateTime(token.lastUsedAt) - : t("admin.tokenNeverUsed")} - - {formatDate(token.createdAt)} -
-
- )} -
- )} -
- ); -} - -/* ─────────────────────── Danger Zone ─────────────────────── */ - -function DangerZoneSection({ id }: { id: string }) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const [wipeMsg, setWipeMsg] = useState(null); - - const wipeAllData = useMutation({ - mutationFn: async () => { - const res = await fetch("/api/admin/data", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ confirm: "DELETE ALL" }), - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || t("common.error")); - return json.data as { - measurements: number; - intakeEvents: number; - medications: number; - }; - }, - onSuccess: (data) => { - queryClient.invalidateQueries(); - setWipeMsg( - t("admin.deletedResult", { - measurements: data.measurements, - medications: data.medications, - intakeEvents: data.intakeEvents, - }), - ); - }, - onError: (err: Error) => { - setWipeMsg(err.message); - }, - }); - - return ( -
-
- -

- {t("admin.dangerZone")} -

-
-
-

{t("admin.deleteAllData")}

-

- {t("admin.deleteAllDescription")} -

-
- - - - - - - - {t("admin.deleteAllConfirm")} - - - {t("admin.deleteAllConfirmDescription")} - - - - {t("common.cancel")} - wipeAllData.mutate()} - > - {t("admin.finalDelete")} - - - - -
- {wipeMsg && ( -

- {wipeMsg} -

- )} -
-
- ); -} - -/* ─────────────────────── User Management ─────────────────────── */ - -function UserManagementSection({ - id, - queryClient, - currentUserId, -}: { - id: string; - queryClient: ReturnType; - currentUserId: string; -}) { - const { t } = useTranslations(); - const [editingUser, setEditingUser] = useState(null); - const [editUsername, setEditUsername] = useState(""); - const [editEmail, setEditEmail] = useState(""); - const [resetUser, setResetUser] = useState(null); - const [resetPassword, setResetPassword] = useState(""); - const [resetMsg, setResetMsg] = useState(null); - - const { data: users } = useQuery({ - queryKey: ["admin", "users"], - queryFn: async () => { - const res = await fetch("/api/admin/users"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as AdminUser[]; - }, - }); - - const updateUser = useMutation({ - mutationFn: async ({ - id, - data, - }: { - id: string; - data: Record; - }) => { - const res = await fetch(`/api/admin/users/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(data), - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || t("common.error")); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["admin", "users"] }); - setEditingUser(null); - toast.success(t("common.saved")); - }, - onError: (err) => { - toast.error( - err instanceof Error && err.message - ? err.message - : t("admin.settingsSaveError"), - ); - }, - }); - - const resetPw = useMutation({ - mutationFn: async ({ id, password }: { id: string; password: string }) => { - const res = await fetch(`/api/admin/users/${id}/reset-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), - }); - const json = await res.json(); - if (!res.ok) throw new Error(json.error || t("common.error")); - }, - onSuccess: () => { - setResetMsg(t("admin.passwordReset")); - setResetPassword(""); - }, - onError: (err: Error) => { - setResetMsg(err.message); - }, - }); - - function startEdit(u: AdminUser) { - setEditingUser(u); - setEditUsername(u.username); - setEditEmail(u.email ?? ""); - } - - function startReset(u: AdminUser) { - setResetUser(u); - setResetPassword(""); - setResetMsg(null); - } - - return ( -
-
- -

{t("admin.userManagement")}

- {users && ( - - {users.length} - - )} -
- - {users ? ( -
- - - - - - - - - - - - - {users.map((u, i) => ( - - - - - - - - - ))} - -
- {t("admin.users")} - - {t("admin.userEmail")} - - {t("admin.userRole")} - - {t("admin.userPasskeys")} - - {t("admin.userCreated")} - - {t("admin.userActions")} -
{u.username} - {u.email || "—"} - - - {u.role} - - {u.passkeyCount} - {formatDate(u.createdAt)} - -
- - - -
-
-
- ) : ( -
- - - {t("admin.loadingUsers")} - -
- )} - - {/* Edit Dialog */} - {editingUser && ( -
-

- {t("admin.editUserTitle", { name: editingUser.username })} -

-
-
- - setEditUsername(e.target.value)} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - /> -
-
- - setEditEmail(e.target.value)} - placeholder={t("common.optional")} - autoComplete="off" - data-lpignore="true" - data-1p-ignore="true" - /> -
-
- - - {updateUser.isError && ( - - {(updateUser.error as Error).message} - - )} -
-
-
- )} - - {/* Password Reset Dialog */} - {resetUser && ( -
-

- {t("admin.resetPasswordTitle", { name: resetUser.username })} -

-
-
- - setResetPassword(e.target.value)} - placeholder={t("admin.newPasswordPlaceholder")} - /> - -
-
- - -
- {resetMsg && ( -

- {resetMsg} -

- )} -
-
- )} -
- ); -} - -/* ─────────────────────── Feedback Inbox ─────────────────────── */ - -type FeedbackStatusType = "OPEN" | "ACKNOWLEDGED" | "RESOLVED" | "ARCHIVED"; -type FeedbackCategoryType = "BUG" | "FEATURE_REQUEST" | "QUESTION" | "OTHER"; - -interface FeedbackItem { - id: string; - userId: string | null; - email: string | null; - category: FeedbackCategoryType; - subject: string; - description: string; - status: FeedbackStatusType; - adminNote: string | null; - gitHubIssueUrl: string | null; - metadata: Record | null; - screenshotBase64: string | null; - createdAt: string; - updatedAt: string; - user: { username: string } | null; -} - -interface FeedbackListResponse { - items: FeedbackItem[]; - meta: { - total: number; - limit: number; - offset: number; - countsByStatus: Partial>; - }; -} - -const STATUS_TABS: FeedbackStatusType[] = [ - "OPEN", - "ACKNOWLEDGED", - "RESOLVED", - "ARCHIVED", -]; - -function FeedbackInboxSection({ id }: { id: string }) { - const { t } = useTranslations(); - const queryClient = useQueryClient(); - const { data: status } = useQuery({ - queryKey: ["admin", "status"], - queryFn: async () => { - const res = await fetch("/api/admin/status"); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as SystemStatus; - }, - }); - const githubConfigured = Boolean(status?.integrations.bugReport?.configured); - - const [activeStatus, setActiveStatus] = useState("OPEN"); - const [selected, setSelected] = useState(null); - - const { data, isLoading } = useQuery({ - queryKey: ["admin", "feedback", activeStatus], - queryFn: async () => { - const res = await fetch( - `/api/admin/feedback?status=${activeStatus}&limit=100`, - ); - if (!res.ok) throw new Error("Failed"); - return (await res.json()).data as FeedbackListResponse; - }, - }); - - const counts = data?.meta.countsByStatus ?? {}; - - function refresh() { - queryClient.invalidateQueries({ queryKey: ["admin", "feedback"] }); - } - - return ( -
-
- -

{t("admin.feedback.title")}

-
-

- {t("admin.feedback.description")} -

- - setActiveStatus(v as FeedbackStatusType)} - className="mt-4" - > - - {STATUS_TABS.map((s) => ( - - - {t( - `admin.feedback.tab${s.charAt(0) + s.slice(1).toLowerCase()}`, - )} - - - {counts[s] ?? 0} - - - ))} - - - {STATUS_TABS.map((s) => ( - - {isLoading ? ( -
- - - {t("admin.feedback.loading")} - -
- ) : !data?.items.length ? ( -

- {t("admin.feedback.noEntries")} -

- ) : ( -
- - - - - - - - - - - - {data.items.map((item, i) => ( - setSelected(item)} - > - - - - - - - ))} - -
- {t("admin.feedback.createdAt")} - - {t("admin.feedback.category")} - - {t("admin.feedback.subject")} - - {t("admin.feedback.user")} - - {t("admin.feedback.actions")} -
- {formatDateTime(item.createdAt)} - - - - {item.subject} - - {item.user?.username ?? t("admin.feedback.anonymous")} - - -
-
- )} -
- ))} -
- - {selected && ( - !open && setSelected(null)} - githubConfigured={githubConfigured} - onMutated={refresh} - /> - )} -
- ); -} - -function FeedbackCategoryBadge({ - category, -}: { - category: FeedbackCategoryType; -}) { - const { t } = useTranslations(); - const map: Record< - FeedbackCategoryType, - { label: string; className: string } - > = { - BUG: { - label: t("admin.feedback.categoryBug"), - className: "bg-red-500/15 text-red-400 border-red-500/30", - }, - FEATURE_REQUEST: { - label: t("admin.feedback.categoryFeature"), - className: "bg-purple-500/15 text-purple-400 border-purple-500/30", - }, - QUESTION: { - label: t("admin.feedback.categoryQuestion"), - className: "bg-blue-500/15 text-blue-400 border-blue-500/30", - }, - OTHER: { - label: t("admin.feedback.categoryOther"), - className: "bg-muted text-muted-foreground border-border", - }, - }; - const cfg = map[category]; - return ( - {cfg.label} - ); -} - -function FeedbackDetailDialog({ - item, - open, - onOpenChange, - githubConfigured, - onMutated, -}: { - item: FeedbackItem; - open: boolean; - onOpenChange: (open: boolean) => void; - githubConfigured: boolean; - onMutated: () => void; -}) { - const { t } = useTranslations(); - const [note, setNote] = useState(item.adminNote ?? ""); - const [issueUrl, setIssueUrl] = useState(item.gitHubIssueUrl); - - const update = useMutation({ - mutationFn: async (payload: { - status?: FeedbackStatusType; - adminNote?: string | null; - }) => { - const res = await fetch(`/api/admin/feedback/${item.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - if (!res.ok) throw new Error(await getApiErrorMessage(res)); - }, - onSuccess: (_, vars) => { - onMutated(); - if (vars.adminNote !== undefined) { - toast.success(t("admin.feedback.noteSaved")); - } - }, - onError: (err) => { - toast.error( - err instanceof Error ? err.message : t("admin.feedback.updateFailed"), - ); - }, - }); - - const archive = useMutation({ - mutationFn: async () => { - const res = await fetch(`/api/admin/feedback/${item.id}`, { - method: "DELETE", - }); - if (!res.ok) throw new Error(await getApiErrorMessage(res)); - }, - onSuccess: () => { - onMutated(); - onOpenChange(false); - }, - onError: (err) => { - toast.error( - err instanceof Error ? err.message : t("admin.feedback.updateFailed"), - ); - }, - }); - - const publish = useMutation({ - mutationFn: async () => { - const res = await fetch(`/api/admin/feedback/${item.id}/github`, { - method: "POST", - }); - if (!res.ok) throw new Error(await getApiErrorMessage(res)); - return (await res.json()).data as { issueUrl: string }; - }, - onSuccess: (data) => { - setIssueUrl(data.issueUrl); - toast.success(t("admin.feedback.publishSuccess")); - onMutated(); - }, - onError: (err) => { - toast.error( - err instanceof Error ? err.message : t("admin.feedback.publishFailed"), - ); - }, - }); - - const meta = item.metadata ?? {}; - const url = typeof meta.url === "string" ? meta.url : null; - const locale = typeof meta.locale === "string" ? meta.locale : null; - const userAgent = typeof meta.userAgent === "string" ? meta.userAgent : null; - const appVersion = - typeof meta.appVersion === "string" ? meta.appVersion : null; - - return ( - - - - - - {item.subject} - - - - - - {t("admin.feedback.submittedBy")}:{" "} - {item.user?.username ?? t("admin.feedback.anonymous")} - - · - {formatDateTime(item.createdAt)} - {issueUrl && ( -
- - {t("admin.feedback.viewIssue")} - - - )} - - - - -
-
- {item.description} -
- - {(url || locale || userAgent || appVersion) && ( -
-

- {t("admin.feedback.metadataHeading")} -

-
- {url && ( - <> -
- {t("admin.feedback.metaUrl")} -
-
{url}
- - )} - {locale && ( - <> -
- {t("admin.feedback.metaLocale")} -
-
{locale}
- - )} - {appVersion && ( - <> -
- {t("admin.feedback.metaAppVersion")} -
-
{appVersion}
- - )} - {userAgent && ( - <> -
- {t("admin.feedback.metaUserAgent")} -
-
{userAgent}
- - )} -
-
- )} - - {item.screenshotBase64 && ( -
-

- {t("admin.feedback.screenshotHeading")} -

- {/* eslint-disable-next-line @next/next/no-img-element */} - Screenshot -
- )} - -
- -