mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
style: apply prettier across the tree
One-shot prettier pass so the new format:check gate starts from a clean baseline. Formatting only — no behaviour change. Generator-owned and vendored artifacts stay untouched via .prettierignore.
This commit is contained in:
1 parent
b3fea2ef59
commit
1f4ba5939d
1000 files changed
+12130
-8385
No files matched your search
+40
-39
@@ -2333,6 +2333,7 @@ iOS-operator-blocking hotfix for REG-11: the Home dashboard tile rendered neithe
|
||||
### Tests
|
||||
|
||||
Four new cases in `src/app/api/dashboard/summary/__tests__/route.test.ts` pin REG-11:
|
||||
|
||||
- BP with a 60-day-old reading → tile emits with historical `latestValue` + `sparkline`
|
||||
- BP with null readings ever → tile NOT emitted
|
||||
- Weight within 7 days → behaviour unchanged (regression guard)
|
||||
@@ -2698,7 +2699,7 @@ self-heal via boot-time backfill on first reach.
|
||||
card; not exposed via any route yet.
|
||||
- **`computeLongWindowSummary(userId, type, windowDays)`** in
|
||||
`summaries-slice.ts` — granularity-routed `count / min / max /
|
||||
mean / sum` aggregate for long-window consumers.
|
||||
mean / sum` aggregate for long-window consumers.
|
||||
- **`rollup-read-cumulative.ts`** — `readCumulativeDaySums` /
|
||||
`readCumulativeDaySumsBatch` / `resolveBucketSum` helpers with
|
||||
legacy-NULL fallback.
|
||||
@@ -2728,7 +2729,7 @@ rides the post-deploy window.
|
||||
- **`/api/measurements?groupBy=day` cumulative path:** consumes
|
||||
`sum_value` directly; eliminates per-type JS aggregation on
|
||||
`ACTIVITY_STEPS / FLIGHTS_CLIMBED / WALKING_RUNNING_DISTANCE /
|
||||
TIME_IN_DAYLIGHT / ACTIVE_ENERGY`.
|
||||
TIME_IN_DAYLIGHT / ACTIVE_ENERGY`.
|
||||
- **`/api/analytics` live-fallback row cap: 347 k → ~5 k.** Trailing
|
||||
425-day `since` cap on the `fetchMeasurementSeriesChunked` per-type
|
||||
loop. Defense-in-depth — the v1.4.38.8 per-type fast-path gate makes
|
||||
@@ -2785,10 +2786,10 @@ false and stranded the whole helper on live SQL across the full
|
||||
### Fixed
|
||||
|
||||
- **`correlations-fast-path` gates only on `BLOOD_PRESSURE_SYS +
|
||||
PULSE + WEIGHT`** — the three types the helper actually reads.
|
||||
PULSE + WEIGHT`** — the three types the helper actually reads.
|
||||
`isFullyCovered(coverage)` AND-term dropped.
|
||||
- **`bp-in-target-fast-path` gates only on `BLOOD_PRESSURE_SYS +
|
||||
BLOOD_PRESSURE_DIA`**. Same fix.
|
||||
BLOOD_PRESSURE_DIA`**. Same fix.
|
||||
- **`health-score-fast-path` gates only on `WEIGHT`**. Same fix.
|
||||
|
||||
Coverage-probe semantics unchanged. Each helper now consults the
|
||||
@@ -3095,7 +3096,7 @@ without changing existing behaviour.
|
||||
`aps.category = "MEDICATION_REMINDER"` on every medication-reminder
|
||||
payload so iOS renders the three action-buttons (Genommen / Snooze
|
||||
15 min / Übersprungen) wired up in iOS v0.5.3. `aps.mutable-content
|
||||
= 1` is set by default so a future Notification Service Extension
|
||||
= 1` is set by default so a future Notification Service Extension
|
||||
can hook the payload without a server-side change. The med-reminder
|
||||
metadata gains `scheduledAt` (ISO 8601) so the iOS "snooze 15 min"
|
||||
action pins to the schedule slot rather than wall-clock delivery
|
||||
@@ -3294,7 +3295,7 @@ dashboard SQL surfaced.
|
||||
list through a Zod `refine` chained on the existing
|
||||
`isValidTimezone` helper. Returns 422 "Invalid IANA timezone" at
|
||||
the write boundary so the new `to_char(measured_at AT TIME ZONE
|
||||
$tz)` SQL in the dashboard summary route cannot be self-DoS'd by
|
||||
$tz)` SQL in the dashboard summary route cannot be self-DoS'd by
|
||||
a corrupted stored value.
|
||||
|
||||
### Tests
|
||||
@@ -3622,7 +3623,7 @@ surfaces city + country when the carrier lookup misses.
|
||||
buckets + a freshly logged WEIGHT type) falls back to the live
|
||||
aggregate so no type silently underreports.
|
||||
- **Daily chart fetches opt into rollup buckets.** `GET
|
||||
/api/measurements?aggregate=daily&source=rollup` reads DAY buckets
|
||||
/api/measurements?aggregate=daily&source=rollup` reads DAY buckets
|
||||
from `measurement_rollups` instead of scanning the measurements
|
||||
table when the window exceeds seven days. The Insights chart
|
||||
fetchers pass `source=rollup`; iOS and other clients are
|
||||
@@ -3689,7 +3690,7 @@ surfaces city + country when the carrier lookup misses.
|
||||
`pickCumulativeDaySum` helper, source-priority-aware in the same
|
||||
shape SLEEP_DURATION already used.
|
||||
- **Insights nav strict-gate** flipped (`if (!availability) return
|
||||
true` → `return false`). Types without measurements no longer
|
||||
true` → `return false`). Types without measurements no longer
|
||||
appear in the strip.
|
||||
- **About → Admin Console.** "Über HealthLog" moved out of the
|
||||
personal dropdown into a new `/admin/about` slug. The public
|
||||
@@ -3715,7 +3716,7 @@ surfaces city + country when the carrier lookup misses.
|
||||
### Schema
|
||||
|
||||
- **Additive `users.insights_exclude_metrics`** (`String[] NOT NULL
|
||||
DEFAULT '{}'`). Migration
|
||||
DEFAULT '{}'`). Migration
|
||||
`0068_v1436_insights_exclude_metrics` is idempotent under re-run.
|
||||
|
||||
### Refactor
|
||||
@@ -3725,7 +3726,7 @@ surfaces city + country when the carrier lookup misses.
|
||||
`applyInsightsExcludeFilter`, `pickCumulativeDaySum`,
|
||||
`probeRollupCoverage`, `isFullyCovered`. Tested standalone.
|
||||
- `ensureUserRollupsFresh` now annotates `{ rollup_refresh_failed:
|
||||
true, rollup_refresh_error: ... }` on failure instead of swallowing
|
||||
true, rollup_refresh_error: ... }` on failure instead of swallowing
|
||||
silently. The read path still returns `{ recomputed: false }` so
|
||||
the response never fails because of a populator hiccup.
|
||||
- Cumulative metric-key lookup hoisted out of a four-level nested
|
||||
@@ -3787,7 +3788,7 @@ zero reader-path impact (serial concurrency, runs in the background).
|
||||
in place. The discovery query finds users with at least one row in
|
||||
`measurements` and zero rows in `measurement_rollups`, then sends
|
||||
one job per uncovered account with `singletonKey:
|
||||
boot-backfill|{userId}` so a fast restart while a fold is queued
|
||||
boot-backfill|{userId}` so a fast restart while a fold is queued
|
||||
doesn't pile up duplicates.
|
||||
- **Best-effort:** any error during discovery is captured in the
|
||||
return value and logged through `workerLog`; the worker boot never
|
||||
@@ -3802,7 +3803,7 @@ zero reader-path impact (serial concurrency, runs in the background).
|
||||
user lands on the queue when a third already-folded user co-exists;
|
||||
zero jobs enqueued when every account is already covered.
|
||||
- Unit suite 4280 → 4285. Integration suite 228 → 230. `pnpm
|
||||
typecheck` + `pnpm lint` clean.
|
||||
typecheck` + `pnpm lint` clean.
|
||||
|
||||
## [1.4.35] — 2026-05-17 — Persistent measurement rollups + partial read-swap
|
||||
|
||||
@@ -3820,7 +3821,7 @@ across every analytics surface is planned for a follow-up.
|
||||
- **New table `measurement_rollups`** keyed on
|
||||
`(user_id, type, granularity, bucket_start)` with
|
||||
`count / mean / min_value / max_value / sd / slope / r2 /
|
||||
computed_at`. Backed by a composite-descending index on
|
||||
computed_at`. Backed by a composite-descending index on
|
||||
`(user_id, type, granularity, bucket_start DESC)`. Additive only —
|
||||
no `ALTER` on existing tables. Migration
|
||||
`0067_v1434_measurement_rollups` is idempotent under re-run.
|
||||
@@ -3896,8 +3897,8 @@ viewport-zoom fix surfaced during the mobile-deep pass.
|
||||
- `tests/integration/withings-oauth.test.ts` covers connect /
|
||||
callback / disconnect plus state-token replay rejection.
|
||||
- `tests/integration/passkey-register.test.ts` covers register-options
|
||||
+ register-verify + tampered-attestation rejection + challenge
|
||||
single-use.
|
||||
- register-verify + tampered-attestation rejection + challenge
|
||||
single-use.
|
||||
- `tests/integration/cache-invalidation-coverage.test.ts` grids the
|
||||
v1.4.34 IW-G invalidation matrix across 5 write surfaces.
|
||||
- Integration suite goes from 197/50 to 222/55. tsc + lint clean.
|
||||
@@ -3969,7 +3970,7 @@ freeze.
|
||||
- **Focus rings on mobile + tablet navigation.** The bottom-nav primary
|
||||
links, the More overflow trigger, and the mobile top-bar user menu
|
||||
trigger gained `focus-visible:ring-ring/50 focus-visible:ring-2
|
||||
focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
|
||||
focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
|
||||
and switch-control users can now see focus on every nav surface.
|
||||
- **Tap-target floor (WCAG 2.5.5).** Input, NativeSelect, Select, and
|
||||
DateTimeInput now ship `h-11 sm:h-10` so mobile clears the 44 px
|
||||
@@ -4203,7 +4204,7 @@ without leaking userIds.
|
||||
gate; Bearer tokens never elevate).
|
||||
- **`ImportJob` schema model + migration.** New Prisma model captures
|
||||
the per-upload state machine (`queued | unpacking | parsing |
|
||||
upserting | done | failed`), the content-hash for idempotency
|
||||
upserting | done | failed`), the content-hash for idempotency
|
||||
short-circuits, and the per-`MeasurementType` ingestion counters
|
||||
the status route surfaces back to the client.
|
||||
- **`lastSeenByType` on `/api/analytics`.** Both the slim
|
||||
@@ -4454,8 +4455,7 @@ the affected surfaces.
|
||||
`very_late`.** Defensive fallback when the dose-window
|
||||
estimator can't resolve a window.
|
||||
- **`/api/insights/generate` POST gating.** Endpoint now refuses
|
||||
on a disabled assistant master flag instead of returning a
|
||||
500.
|
||||
on a disabled assistant master flag instead of returning a 500.
|
||||
- **Mood Log overflow on narrow viewports.** Cards no longer
|
||||
shred their internal grid below 360 CSS px.
|
||||
- **F13 username readability.** Header username now respects
|
||||
@@ -4802,7 +4802,7 @@ permission picker and the future Insights nav.
|
||||
### Added
|
||||
|
||||
- **Daily-stats `externalId` helper.** `dailyStatsExternalId(hkId,
|
||||
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
|
||||
date)` mints `"stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>"`
|
||||
alongside the v1.4.29 `CUMULATIVE_HK_TYPES` set. The shape is
|
||||
locked in the iOS coordination notes (§12). iOS
|
||||
emits one row per day per cumulative type via
|
||||
@@ -4885,11 +4885,12 @@ encounter them in read paths because no source writes them yet, and
|
||||
the codegen path will pick them up on the next iOS regeneration.
|
||||
|
||||
**Cutover sequence.** v1.4.30 ships with the helper + drain script
|
||||
+ server tolerance for both shapes. The next iOS TestFlight build
|
||||
adopts `HealthKitStatisticsService.swift` and starts posting daily-
|
||||
aggregated rows for the five cumulative types. Operator runs the
|
||||
drain script once after the new TestFlight cuts over; per-sample
|
||||
row pressure on `Measurement` drops 50-200× for cumulative types.
|
||||
|
||||
- server tolerance for both shapes. The next iOS TestFlight build
|
||||
adopts `HealthKitStatisticsService.swift` and starts posting daily-
|
||||
aggregated rows for the five cumulative types. Operator runs the
|
||||
drain script once after the new TestFlight cuts over; per-sample
|
||||
row pressure on `Measurement` drops 50-200× for cumulative types.
|
||||
|
||||
## [1.4.29.1] — 2026-05-16 — Daily-step aggregation hotfix
|
||||
|
||||
@@ -5146,7 +5147,7 @@ internal-only `/api/internal/web-vitals` beacon route).
|
||||
GLP-1 row painted with a brand icon and a middle-dot separator;
|
||||
every other row painted two-line without an icon. Both routes
|
||||
through a shared `<MedicationCardHeader>` now — line 1 is `{name}
|
||||
{dose}`, line 2 is the class label plus state badges. The GLP-1
|
||||
{dose}`, line 2 is the class label plus state badges. The GLP-1
|
||||
outlier shape is gone.
|
||||
- **HealthScore card came up short below the action+prompts column.**
|
||||
The card now opts into `flex h-full flex-col` with the disclaimer
|
||||
@@ -5184,7 +5185,7 @@ internal-only `/api/internal/web-vitals` beacon route).
|
||||
- **Medications detail-page chrome collapsed to one heading scale.**
|
||||
Every section on `/medications/[id]` mounts through
|
||||
`<MedicationDetailSection>` (`text-base font-semibold leading-6
|
||||
tracking-tight`). DrugLevelChart's standalone header migrates to
|
||||
tracking-tight`). DrugLevelChart's standalone header migrates to
|
||||
`<h2>` with the same classes. Micro labels lift from
|
||||
`text-[10px]` / `text-[11px]` to `text-xs` across Scheduling,
|
||||
Titration, SideEffects. Three scales survive the page: heading,
|
||||
@@ -5709,7 +5710,7 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
|
||||
`router.replace`s to a clean URL.
|
||||
- **`/api/version` register button below the tap floor.** The
|
||||
`/auth/register` submit button promotes to `size="lg" min-h-11
|
||||
w-full` so the primary action stays finger-tap reachable on
|
||||
w-full` so the primary action stays finger-tap reachable on
|
||||
narrow viewports.
|
||||
- **Workouts pagination broken under canonical dedup.** Pulling the
|
||||
full filtered set and slicing post-dedup yields the correct
|
||||
@@ -5805,8 +5806,8 @@ is additive, `IF NOT EXISTS`-guarded, and forward-only.
|
||||
contract and the SSR `useIsMobile` first-paint.
|
||||
- **16 new `metric-availability.test.ts` cases** cover each metric ×
|
||||
`{has data, no data, undefined summaries, missing summary entry,
|
||||
BMI-from-WEIGHT derivation, sys-vs-dia independence,
|
||||
mood/medication overrides}`.
|
||||
BMI-from-WEIGHT derivation, sys-vs-dia independence,
|
||||
mood/medication overrides}`.
|
||||
- **6 new `insights-tab-strip.test.tsx` cases** assert
|
||||
backward-compat without `availability`, pill-drop when data is
|
||||
missing, overview pill always renders, mood + medication
|
||||
@@ -5974,7 +5975,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
browser-detect maps `fr|es|it|pl` to the corresponding bundle.
|
||||
- **Coach — native first-party system prompts across six locales.**
|
||||
Coach + insights system prompts move from EN body + `REPLY
|
||||
LANGUAGE` footer to native per-locale bodies; the safety contract
|
||||
LANGUAGE` footer to native per-locale bodies; the safety contract
|
||||
matrix lives as YAML per locale (single source of truth across
|
||||
drafting + tests). The refusal-probe matrix in CI catches
|
||||
cross-locale prompt regressions before they reach the dispatch
|
||||
@@ -5985,7 +5986,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
- **Personal Records end-to-end — schema, detection worker, badge,
|
||||
push opt-in.** Migration 0054 introduces the `PersonalRecord`
|
||||
table + `PersonalRecordDirection` helper; `GET
|
||||
/api/personal-records` is paginated (default twenty-five, max two
|
||||
/api/personal-records` is paginated (default twenty-five, max two
|
||||
hundred). A pg-boss worker sweeps MAX / MIN per metric and the
|
||||
workout slots on every batch-ingest + a thirty-minute fallback
|
||||
cron (concurrency five, warmup gate so the very first datapoint
|
||||
@@ -6056,7 +6057,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
GLP-1 detail page renders the user's current step + remaining
|
||||
ladder as observational reference, framed as reference-not-advice
|
||||
and bound by GROUND RULE 15. `GET
|
||||
/api/medications/[id]/titration-ladder` returns the structured
|
||||
/api/medications/[id]/titration-ladder` returns the structured
|
||||
ladder + current step.
|
||||
- **GLP-1 — pen-and-vial inventory with a 30-day in-use clock.**
|
||||
Migration 0056 introduces the `MedicationInventoryItem` table +
|
||||
@@ -6110,7 +6111,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
and the per-row collapse affordance comes off.
|
||||
- **DELETE `/api/measurements/by-external-ids` for iOS deletion
|
||||
sync.** Idempotent batch delete keyed on `(user, source,
|
||||
externalId)` tuples; rate-limited the same way as the batch
|
||||
externalId)` tuples; rate-limited the same way as the batch
|
||||
ingest endpoint.
|
||||
- **Multi-arch Docker image.** GHCR publish workflow builds
|
||||
`linux/amd64` on `ubuntu-latest` plus `linux/arm64` on
|
||||
@@ -6118,7 +6119,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
Macs and arm64 clouds now pull native; the previously-stale
|
||||
README claim is accurate again.
|
||||
- **GLP-1 endpoint hardening.** `POST
|
||||
/api/medications/[id]/glp1` now parses through bounded Zod
|
||||
/api/medications/[id]/glp1` now parses through bounded Zod
|
||||
schemas (`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`)
|
||||
with length-capped notes, finite-number guards on dose value,
|
||||
and bounded `effectiveFrom`. Every write produces an audit-log
|
||||
@@ -6203,7 +6204,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
`src/lib/medications/route-guards.ts` rather than open-coding
|
||||
the lookup; rate-limit headers across the same surface align
|
||||
on the option-bag form `apiError(..., { headers:
|
||||
rateLimitHeaders(rl) })`.
|
||||
rateLimitHeaders(rl) })`.
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -6283,7 +6284,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
single-entry POST stripped it on the Zod boundary. Mirror parity
|
||||
restored.
|
||||
- **Personal-records endpoint unpaginated.** `GET
|
||||
/api/personal-records` now clamps `?limit` (default twenty-five,
|
||||
/api/personal-records` now clamps `?limit` (default twenty-five,
|
||||
maximum two hundred); the unbounded read is gone.
|
||||
- **Withings activity sync bucketed positive-offset users into the
|
||||
wrong day.** The per-day row was anchored at 23:59:59 UTC, so a
|
||||
@@ -6402,7 +6403,7 @@ refuses drug-level estimates with MDR + MDCG 2021-24 cites).
|
||||
2024 pharmacokinetic reference. The acknowledgment version
|
||||
re-prompts the user on bump.
|
||||
- **GLP-1 convenience endpoint hardened.** `POST
|
||||
/api/medications/[id]/glp1` parses through bounded Zod schemas
|
||||
/api/medications/[id]/glp1` parses through bounded Zod schemas
|
||||
(`glp1DoseChangePostSchema`, `glp1InventoryPostSchema`), enforces
|
||||
length caps on notes, finite-number guards on dose value, and
|
||||
bounded `effectiveFrom`; every write produces an audit-log row
|
||||
@@ -6608,7 +6609,7 @@ estimates and source citations, is tracked internally. Headline items:
|
||||
- **iOS Swift app — P1 through P5.** Login + dashboard + widget
|
||||
(P1); Apple Health sync (P2); Coach extended for HRV / sleep /
|
||||
resting HR / steps (P3); per-metric APNs alerts (P4); workouts
|
||||
+ GeoJSON routes (P5). All server contracts locked in v1.4.25.
|
||||
- GeoJSON routes (P5). All server contracts locked in v1.4.25.
|
||||
- **Workout ingest API matching the v1.5 iOS contract.**
|
||||
Schema shipped this release; endpoint signature finalised
|
||||
during the iOS sprint.
|
||||
|
||||
@@ -32,19 +32,19 @@ Prefer patch bumps. A release dominated by bug fixes plus additive features with
|
||||
|
||||
## Tech stack at a glance
|
||||
|
||||
| Layer | Tech | Notes |
|
||||
|---|---|---|
|
||||
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
|
||||
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
|
||||
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
|
||||
| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
|
||||
| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
|
||||
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
|
||||
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
|
||||
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
|
||||
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
|
||||
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
|
||||
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
|
||||
| Layer | Tech | Notes |
|
||||
| ------------------ | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Runtime | Node 22 (Alpine) + pnpm 10.31 | Node version fixed only by the Dockerfile base image; no `.nvmrc` or `engines`. |
|
||||
| Framework | Next.js 16.2.6 — App Router, `output: "standalone"` | SWC strips `console.*` in prod except `error/warn`. |
|
||||
| Language | TypeScript 6, `strict: true`, `moduleResolution: "bundler"` | Path alias `@/*` → `./src/*`. `scripts/` excluded from typecheck. |
|
||||
| UI | React 19 (exact pin), Tailwind 4, shadcn/ui (new-york style, zinc base), Radix, Lucide | Recharts 3 for charts — stays; replacement requires explicit approval. |
|
||||
| Persistence | PostgreSQL 16 + Prisma 7.8 — 80 models | Generated client at `src/generated/prisma`. Production image ships a separate `/opt/prisma-cli` install + `/opt/pg-boss` runtime install (see `Dockerfile:93-100`). |
|
||||
| Queue | pg-boss 12.18 | Cron + retry semantics live in Postgres tables; workers under `src/lib/jobs/`. |
|
||||
| Auth | `@simplewebauthn/server` 13, `@node-rs/argon2` 2 | Passkey + Argon2id password, server-side sessions in Postgres. |
|
||||
| Notifications | `@parse/node-apn` 8 (APNs), `web-push` 3 (VAPID), raw fetch for Telegram + ntfy | Per-channel `recordPushAttempt` row, hard-reject classification. |
|
||||
| Forms / validation | `react-hook-form` 7 + `zod` 4 + `@hookform/resolvers` + `zod-openapi` 5 | The Zod registry is the source of truth for `docs/api/openapi.yaml`. |
|
||||
| Testing | Vitest 4 (unit + integration), Playwright 1.60 (e2e), `@axe-core/playwright` | Integration runs against a `testcontainers/postgresql` Postgres 16. |
|
||||
| Container | Multi-stage Alpine Dockerfile, GHCR multi-arch (`amd64` + `arm64`) | `pull_policy: always` in `docker-compose.yml` is load-bearing — without it Docker re-uses the stale `:latest` digest. |
|
||||
|
||||
More detail in `.planning/codebase/tech.md`, generated as part of the v1.5.2 codebase audit.
|
||||
|
||||
@@ -110,23 +110,23 @@ The architecture map in `.planning/codebase/arch.md` walks each layer with file:
|
||||
|
||||
## Critical-files map
|
||||
|
||||
| Concern | File | What it owns |
|
||||
|---|---|---|
|
||||
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
|
||||
| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
|
||||
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
|
||||
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
|
||||
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
|
||||
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
|
||||
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
|
||||
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
|
||||
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
|
||||
| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
|
||||
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
|
||||
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
|
||||
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
|
||||
| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
|
||||
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
|
||||
| Concern | File | What it owns |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| HTTP edge | `src/proxy.ts` | Public-path allowlist, demo-mode block, onboarding redirect, request-id + CSP nonce, every security header, worker-only refusal. |
|
||||
| API kit | `src/lib/api-handler.ts` | `apiHandler` wrapper, `requireAuth` / `requireAdmin`, idempotency plumbing, GlitchTip forwarder. |
|
||||
| Response envelope | `src/lib/api-response.ts` | `apiSuccess` / `apiError`, `safeJson` (now with opt-in `maxBytes`), trusted-proxy IP resolver, `returnAllZodIssues` + `sanitiseZodIssues`. |
|
||||
| Crypto at rest | `src/lib/crypto.ts` | AES-256-GCM with versioned key ids, `extractKeyId`, fail-closed loader, rotation primitives. Rotation CLI: `scripts/rotate-encryption-key.ts`. |
|
||||
| Session | `src/lib/auth/session.ts` + `src/lib/auth/secure-cookie.ts` | Postgres-backed sessions, sliding 30-day expiry, `shouldEmitSecureCookie()` the one source of truth for the `Secure` flag (every cookie setter routes through it). |
|
||||
| Refresh tokens | `src/lib/auth/refresh-token.ts` | Per-device one-time-use rotation with reuse-detection that revokes the device's token family. |
|
||||
| Bearer tokens | `src/lib/auth/hmac.ts` + `src/lib/auth/issue-token.ts` | HMAC-SHA256 hashing under `API_TOKEN_HMAC_KEY`; no plaintext path; `lastUsedAt` updated fire-and-forget. |
|
||||
| Rollup tier | `src/lib/rollups/` | DAY / WEEK / MONTH / YEAR pre-aggregations. Read-swap pattern: try the rollup, fall back to live SQL only on coverage miss. Boot-time `rollup-full-backfill` queue handles new accounts. |
|
||||
| Compliance | `src/lib/analytics/compliance.ts` | Cadence-aware medication compliance — daysOfWeek + intervalWeeks honoured across the eight call sites that surface a rate (Coach prompt, BP-status gate, dashboard pillar, …). |
|
||||
| AI providers | `src/lib/ai/provider.ts` (`resolveProvider`) + `src/lib/ai/provider-chain.ts` (`PROVIDER_CHAIN_TYPES`) + `src/lib/ai/{openai,anthropic,local,codex,mock}-client.ts` | Five providers, hand-rolled fetch over the documented wire (no vendor SDKs). Mock is excluded structurally by the `PROVIDER_CHAIN_TYPES` allowlist — production cannot reach it. |
|
||||
| Notification dispatcher | `src/lib/notifications/dispatcher.ts` + `src/lib/notifications/senders/` | APNs → Telegram → ntfy → Web Push cascade, hard-reject classification, `push_attempts` ledger with 90-day retention. |
|
||||
| Coach | `src/app/api/insights/chat/route.ts` + `src/lib/ai/coach/` | SSE stream, budget gate + per-user rate gate, refusal detector, snapshot builder, message persistence with `encryptedContent` Bytes column. |
|
||||
| OpenAPI | `src/lib/openapi/registry.ts` + `src/lib/openapi/routes/` (23 modules, `index.ts` barrel) | Source of truth for `docs/api/openapi.yaml`. |
|
||||
| Schema | `prisma/schema.prisma` (80 models) | `cuid()` PKs, `snake_case` columns via `@map`, encrypted columns mostly under `*Encrypted` (search the file). |
|
||||
| Compose | `docker-compose.yml` | `app` + `db`. Env-var whitelist under `environment:` — vars not listed never reach the container. `pull_policy: always` is load-bearing. |
|
||||
|
||||
`.planning/codebase/arch.md` carries the full annotated walk for every section.
|
||||
|
||||
@@ -181,21 +181,22 @@ The native SwiftUI client lives in a separate repository and rides public beta v
|
||||
|
||||
## Pointers
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| Public face | `README.md` |
|
||||
| Release history | `CHANGELOG.md` |
|
||||
| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
|
||||
| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
|
||||
| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
|
||||
| Self-hosting guide | `docs/self-hosting/` |
|
||||
| Public API contract | `docs/api/openapi.yaml` |
|
||||
| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
|
||||
| iOS coordination notes | `.planning/ios-coord/` |
|
||||
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
|
||||
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
|
||||
| What | Where |
|
||||
| ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Public face | `README.md` |
|
||||
| Release history | `CHANGELOG.md` |
|
||||
| Codebase audit (v1.5.2) | `.planning/codebase/{tech,arch,quality,concerns}.md` |
|
||||
| Security audit (v1.5.2) | `.planning/security-audit-v1.5.2.md` + `.planning/security-audit-findings/*` |
|
||||
| Operator runbooks | `docs/ops/` (deploy, encryption-key-rotation, env-check, backup-restore, migrations) |
|
||||
| Self-hosting guide | `docs/self-hosting/` |
|
||||
| Public API contract | `docs/api/openapi.yaml` |
|
||||
| Architecture diagrams | `docs/diagrams/` (rendered through `docs.healthlog.dev`) |
|
||||
| iOS coordination notes | `.planning/ios-coord/` |
|
||||
| Maintainer working notes | `.planning/PROJECT.md`, `.planning/ROADMAP.md`, `.planning/STATE.md` (gitignored release scratch lives alongside; the patterns are in `.gitignore`) |
|
||||
| Personal preferences + collaboration memory | `~/.claude/projects/-Users-marc-Projects-HealthLog/memory/MEMORY.md` (local to the maintainer's working copy; not in the repo) |
|
||||
|
||||
## Agent context hygiene (ECONNRESET / oversized-request prevention)
|
||||
- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET` on large turns. Regenerate via `pnpm prisma generate` if needed; never open it. (Enforced via `.claude/settings.local.json` Read-deny.)
|
||||
|
||||
- **NEVER read `src/generated/**`** — Prisma-generated client (~9 MB, 83 files incl. a 224 KB+ inline schema). Reading/grepping it explodes the request context → 10-min "cogitations" + `ECONNRESET`on large turns. Regenerate via`pnpm prisma generate`if needed; never open it. (Enforced via`.claude/settings.local.json` Read-deny.)
|
||||
- Avoid bulk-reading other large artifacts: `pnpm-lock.yaml`, `CHANGELOG.md`, `messages/*.json`, `docs/api/openapi.yaml` — grep narrowly instead of full reads.
|
||||
- For very large multi-agent actions: cap concurrent sub-agents (many simultaneous streaming requests + big context is the worst combo for connection resets). When on a VPN (Tailscale MTU 1280 / iCloud Private Relay), large requests can hit MTU black-holes — disable the tunnel for heavy sessions.
|
||||
@@ -32,7 +32,7 @@ Separate the two naming axes cleanly:
|
||||
|
||||
- **UI copy is always localised.** Every visible string resolves through an
|
||||
i18n `t()` key and is translated across all six locales (`de / en / es / fr /
|
||||
it / pl`). The user never sees a raw identifier.
|
||||
it / pl`). The user never sees a raw identifier.
|
||||
- **Internal identifiers are English.** Route slugs (`/insights/<slug>`), the
|
||||
tile-layout enum (`INSIGHTS_TILE_IDS`), query keys, and the internal
|
||||
slug→metric / slug→target maps all use established English terms. English is
|
||||
@@ -54,7 +54,7 @@ The migration must not break either client-visible boundary:
|
||||
navigation keep resolving.
|
||||
- **Tile-layout contract.** The layout endpoint keeps **accepting** the legacy
|
||||
German tile ids on input. The Zod enum is built from
|
||||
`ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids *plus* the legacy German
|
||||
`ACCEPTED_INSIGHTS_TILE_IDS` (canonical English ids _plus_ the legacy German
|
||||
aliases), so a `PUT` carrying old ids validates rather than tripping a 422.
|
||||
`normalizeInsightsTileId` collapses each legacy id onto its canonical English
|
||||
replacement; this normalisation runs on **both** the read path
|
||||
|
||||
@@ -13,16 +13,16 @@ row. The fold only runs when cycle tracking is enabled for the account.
|
||||
|
||||
## Day-log fields
|
||||
|
||||
| HealthKit category type | CycleDayLog field | Values |
|
||||
|---|---|---|
|
||||
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
|
||||
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
|
||||
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
|
||||
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
|
||||
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
|
||||
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
|
||||
| HealthKit category type | CycleDayLog field | Values |
|
||||
| ------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `MenstrualFlow` | `flow` | unspecified→LIGHT, light→LIGHT, medium→MEDIUM, heavy→HEAVY, none→NONE |
|
||||
| `IntermenstrualBleeding` | `intermenstrualBleeding` | presence → `true` |
|
||||
| `CervicalMucusQuality` | `cervicalMucus` | dry→DRY, sticky→STICKY, creamy→CREAMY, watery→WATERY, eggWhite→EGG_WHITE |
|
||||
| `OvulationTestResult` | `ovulationTest` | negative→NEGATIVE, lhSurge→POSITIVE_LH_SURGE, indeterminate→INDETERMINATE, estrogenSurge→ESTROGEN_SURGE |
|
||||
| `SexualActivity` | `sexualActivity` + `protectedSex` | presence → `true`; `HKMetadataKeySexualActivityProtectionUsed` → `protectedSex` |
|
||||
| `PregnancyTestResult` | `pregnancyTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `ProgesteroneTestResult` | `progesteroneTest` | negative→NEGATIVE, positive→POSITIVE, indeterminate→INDETERMINATE |
|
||||
| `Contraceptive` | `contraceptive` (+ profile nudge) | unspecified→UNSPECIFIED, implant→IMPLANT, injection→INJECTION, iud→IUD, ring→INTRAVAGINAL_RING, oral→ORAL, patch→PATCH, emergency→EMERGENCY |
|
||||
|
||||
HealthLog's `SPOTTING` flow level has no HealthKit counterpart; it is only ever
|
||||
produced by a manual entry. HealthKit `unspecified` flow maps to `LIGHT` (a
|
||||
@@ -31,21 +31,21 @@ logged-but-unspecified bleeding day is at least light flow).
|
||||
## Symptom category types → seeded symptom keys
|
||||
|
||||
Symptom category samples link to the seeded `CycleSymptom` catalogue by key. A
|
||||
sample whose severity is *not present* (codepoint 1) does NOT create a link.
|
||||
sample whose severity is _not present_ (codepoint 1) does NOT create a link.
|
||||
|
||||
| HealthKit category type | CycleSymptom key |
|
||||
|---|---|
|
||||
| `AbdominalCramps` | `cramps` |
|
||||
| `Headache` | `headache` |
|
||||
| `Bloating` | `bloating` |
|
||||
| `Acne` | `acne` |
|
||||
| `BreastPain` | `breast_tenderness` |
|
||||
| `Fatigue` | `fatigue` |
|
||||
| `LowerBackPain` | `back_pain` |
|
||||
| `SleepChanges` | `insomnia` |
|
||||
| `MoodChanges` | `mood_swings` |
|
||||
| `AppetiteChanges` | `food_cravings` |
|
||||
| `Nausea` | `nausea` |
|
||||
| HealthKit category type | CycleSymptom key |
|
||||
| ----------------------- | ------------------- |
|
||||
| `AbdominalCramps` | `cramps` |
|
||||
| `Headache` | `headache` |
|
||||
| `Bloating` | `bloating` |
|
||||
| `Acne` | `acne` |
|
||||
| `BreastPain` | `breast_tenderness` |
|
||||
| `Fatigue` | `fatigue` |
|
||||
| `LowerBackPain` | `back_pain` |
|
||||
| `SleepChanges` | `insomnia` |
|
||||
| `MoodChanges` | `mood_swings` |
|
||||
| `AppetiteChanges` | `food_cravings` |
|
||||
| `Nausea` | `nausea` |
|
||||
|
||||
## Profile-level signals
|
||||
|
||||
@@ -64,8 +64,8 @@ the temperature-trend ovulation method directly from `Measurement`.
|
||||
|
||||
## Deferred
|
||||
|
||||
| HealthKit category type | Status |
|
||||
|---|---|
|
||||
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
|
||||
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
|
||||
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
|
||||
| HealthKit category type | Status |
|
||||
| ------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `Pregnancy`, `Lactation` | Deferred — no `CycleProfile` column yet (pregnancy-mode is a later release). |
|
||||
| `BleedingDuringPregnancy`, `BleedingAfterPregnancy` | Deferred — pregnancy-mode. |
|
||||
| `IrregularMenstrualCycles`, `InfrequentMenstrualCycles`, `ProlongedMenstrualPeriods`, `PersistentIntermenstrualBleeding` | Server-derived from the cycle engine, never ingested. |
|
||||
@@ -11,6 +11,7 @@ last_updated: 2026-05-15
|
||||
## TL;DR
|
||||
|
||||
Three submission gates that need the maintainer's hand at ASC:
|
||||
|
||||
1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix))
|
||||
2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2)
|
||||
3. Standard metadata (name, description, screenshots, categories, age rating)
|
||||
@@ -24,9 +25,11 @@ Plus pre-submit final checks.
|
||||
**Decision**: HealthLog is **NOT a medical device** under EU MDR 2017/745 / FDA 21 CFR 880 / Apple's interpretation.
|
||||
|
||||
**Recommended wording in the declaration form**:
|
||||
|
||||
> HealthLog is a personal health-data logging and visualization app. It displays user-entered observations + integrates with Apple HealthKit, Withings, and manual entry. It does not diagnose, treat, prescribe, or recommend dosing. The AI Coach surfaces are explicit-refusal-gated (GROUND RULES 9 + 15) against any medical-device-class output. The drug-level estimation chart is display-only behind a versioned MDR-acknowledgment dialog citing EU 2017/745 + MDCG 2021-24. For medical advice, users must consult licensed clinicians.
|
||||
|
||||
**What to check**:
|
||||
|
||||
- [ ] Check "No" on the medical-device toggle
|
||||
- [ ] Add the description above to the medical-device-justification field if Apple shows one
|
||||
- [ ] Cross-reference the in-app `/privacy` page (https://<your-instance>/privacy) §7 MDR-boundary
|
||||
@@ -44,22 +47,26 @@ Plus pre-submit final checks.
|
||||
For each category below: **Linked to user, used for App Functionality + Account Management**. Optional purposes: never enable Analytics, Tracking, Advertising, or Third-Party Tracking.
|
||||
|
||||
**Health & Fitness**
|
||||
|
||||
- Health: yes (all HKQuantityType + HKCategoryType the iOS app reads — see `/privacy` page §2)
|
||||
- Fitness: yes (steps, distance, active energy, workouts)
|
||||
- Purpose: App Functionality
|
||||
- Linked to user: yes
|
||||
|
||||
**Contact Info**
|
||||
|
||||
- Email Address: yes (for account login + GDPR data-subject correspondence)
|
||||
- Purpose: App Functionality + Account Management
|
||||
- Linked to user: yes
|
||||
|
||||
**Sensitive Info**
|
||||
|
||||
- Blood pressure, glucose, medications, drug-related observations: yes
|
||||
- Purpose: App Functionality
|
||||
- Linked to user: yes
|
||||
|
||||
**User Content**
|
||||
|
||||
- Emails or Text Messages: NO (the AI Coach is in-app conversation, but iOS does not send emails)
|
||||
- Photos or Videos: NO
|
||||
- Audio Data: NO (HealthKit audio-exposure is environmental dB; not the user's voice)
|
||||
@@ -69,12 +76,14 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- Linked to user: yes
|
||||
|
||||
**Identifiers**
|
||||
|
||||
- User ID: yes (server-side user UUID)
|
||||
- Device ID: yes (X-Device-Id Keychain-stored UUID, APNs push token)
|
||||
- Purpose: App Functionality + Account Management
|
||||
- Linked to user: yes
|
||||
|
||||
**Other Data Types**
|
||||
|
||||
- Other Data Types: yes (general medical observations not fitting above)
|
||||
- Purpose: App Functionality
|
||||
- Linked to user: yes
|
||||
@@ -97,6 +106,7 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
## Section 3 — Standard Metadata Checklist
|
||||
|
||||
### App Information
|
||||
|
||||
- [ ] Name: "HealthLog"
|
||||
- [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach")
|
||||
- [ ] Primary category: Health & Fitness
|
||||
@@ -105,25 +115,30 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- [ ] Age rating: 17+ (because Sensitive Health Info content)
|
||||
|
||||
### Description
|
||||
|
||||
- [ ] Long-form description (4000 char max): cover Insights + Coach + GLP-1 + Withings + Apple HealthKit + open-source nature + EU GDPR compliance
|
||||
- [ ] What's New (version-by-version notes): v0.3.0 = "Server-sync, comprehensive Apple HealthKit support across 19 sample types, smart Insights, AI Coach, Health Score, GLP-1 tracking with Research-Mode-gated drug-level chart, interactive charts with drill-down, push notifications, deep links, and account deletion."
|
||||
|
||||
### Screenshots
|
||||
|
||||
- [ ] 6.5" iPhone screenshots: cover Onboarding (welcome carousel + permissions), Dashboard (Health Score tile + tile-strip), Insights (sub-page with severity sort), Coach (refusal example to show MDR boundary), Medications (GLP-1 detail stack), Settings (Research Mode toggle + account deletion).
|
||||
- [ ] 5.5" iPhone screenshots: same set (Apple's legacy size).
|
||||
- [ ] iPad screenshots if supporting iPad.
|
||||
|
||||
### URLs
|
||||
|
||||
- [ ] Marketing URL: https://<your-instance> (or the marketing site once the landing repo's v1.4.25 is deployed)
|
||||
- [ ] Privacy Policy URL: **https://<your-instance>/privacy** (ships with v1.4.25.1 hotfix)
|
||||
- [ ] Support URL: https://github.com/MBombeck/HealthLog/issues
|
||||
|
||||
### App Review Information
|
||||
|
||||
- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
|
||||
- [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim.
|
||||
- [ ] Sign-in required: yes (provide demo credentials)
|
||||
|
||||
### Pricing + Availability
|
||||
|
||||
- [ ] Free
|
||||
- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch)
|
||||
|
||||
@@ -154,6 +169,7 @@ Run through this list:
|
||||
---
|
||||
|
||||
**Source links**:
|
||||
|
||||
- Apple Health App Guidelines: https://developer.apple.com/app-store/review/guidelines/#health-and-health-research
|
||||
- Apple App Privacy Details: https://developer.apple.com/app-store/app-privacy-details/
|
||||
- EU MDR 2017/745: https://eur-lex.europa.eu/eli/reg/2017/745/oj
|
||||
|
||||
@@ -6,13 +6,13 @@ pages as `<img>` tags. Mirrored into `healthlog-docs/src/assets/diagrams/`
|
||||
and `healthlog-landing/public/diagrams/` so each surface ships its own
|
||||
copy.
|
||||
|
||||
| File | Story it tells |
|
||||
| ---- | -------------- |
|
||||
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
|
||||
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
|
||||
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
|
||||
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
|
||||
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
|
||||
| File | Story it tells |
|
||||
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `01-data-flow.svg` | Sources (Withings, Apple Health export.zip, iPhone HealthKit, manual, moodLog) → ingest endpoints → Measurement + MeasurementRollup → reads (rollup probe / live SQL fallback / cache) → surfaces (dashboard, insights, Coach, doctor PDF). |
|
||||
| `02-coach-pipeline.svg` | User question → snapshot builder → prompt assembler → provider chain (ChatGPT OAuth / OpenAI / Anthropic / local / admin-shared) → response parser → cited reply with mini-charts. |
|
||||
| `03-self-hosting-topology.svg` | Internet → reverse proxy (TLS) → Next.js app (port 3000) ↔ pg-boss worker ↔ Postgres 16, plus GHCR image pull, optional Coolify autodeploy, optional S3 off-host backup. |
|
||||
| `04-source-priority.svg` | Three sources for the same metric on the same day → resolver picks one canonical row per (cumulative vs point) ladder. Losing rows stay in the audit trail. |
|
||||
| `05-security-model.svg` | Three concentric layers — auth perimeter (passkey + password fallback), session perimeter (HttpOnly cookie + Postgres sessions), encrypted core (AES-256-GCM versioned keys). Side rails: rate limiter, audit log, HMAC tokens, CSP/HSTS, SSRF guard. |
|
||||
|
||||
## Editing
|
||||
|
||||
|
||||
@@ -9,12 +9,12 @@ Ollama endpoint can fall back from one to the other automatically.
|
||||
|
||||
## Provider matrix
|
||||
|
||||
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
|
||||
| -------- | ---------------- | ------------- | -------- | -------------- |
|
||||
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
|
||||
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
|
||||
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
|
||||
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
|
||||
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
|
||||
| --------------- | --------------------------------------------- | ------------------- | ----------------------------------------- | ------------------------------------- |
|
||||
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
|
||||
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-sonnet-4-6` | Anthropic SDK default | Measurement context sent to Anthropic |
|
||||
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
|
||||
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
|
||||
|
||||
The four spellings above are the canonical `User.aiProvider` enum
|
||||
values. Anything else falls through to the admin-shared OpenAI key
|
||||
|
||||
@@ -97,12 +97,12 @@ they triggered.
|
||||
The parser folds four XML element types into the existing HealthLog
|
||||
schema (CHANGELOG v1.4.34):
|
||||
|
||||
| XML element | Maps to | Notes |
|
||||
| ----------- | ------- | ----- |
|
||||
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
|
||||
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
|
||||
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
|
||||
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
|
||||
| XML element | Maps to | Notes |
|
||||
| ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
|
||||
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
|
||||
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
|
||||
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
|
||||
|
||||
The HK quantity types that map to HealthLog metric types live in
|
||||
`src/lib/measurements/apple-health-mapping.ts`. Anything outside the
|
||||
@@ -187,13 +187,13 @@ Override per-user via the Sources section of `/settings/thresholds`.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | What you see | Recovery |
|
||||
| ------- | ------------ | -------- |
|
||||
| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
|
||||
| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
|
||||
| Unsupported compression method | `failureReason: "Unsupported ZIP compression method <n> for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
|
||||
| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
|
||||
| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
|
||||
| Failure | What you see | Recovery |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
|
||||
| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
|
||||
| Unsupported compression method | `failureReason: "Unsupported ZIP compression method <n> for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
|
||||
| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
|
||||
| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
|
||||
|
||||
The wide-event log line `import.apple-health.kickoff` (or
|
||||
`import.apple-health.kickoff.denied` on failure) captures every
|
||||
|
||||
@@ -36,8 +36,12 @@ arrays:
|
||||
|
||||
```json
|
||||
{
|
||||
"measurements": [ /* … */ ],
|
||||
"moodEntries": [ /* … */ ]
|
||||
"measurements": [
|
||||
/* … */
|
||||
],
|
||||
"moodEntries": [
|
||||
/* … */
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -48,26 +52,26 @@ skipped and counted under `skipped` in the response.
|
||||
|
||||
### Measurement entry
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
|
||||
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
|
||||
| `unit` | yes | string | The canonical unit for the type (see the table). |
|
||||
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
|
||||
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
|
||||
| `notes` | no | string | Optional free text. |
|
||||
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
|
||||
| Field | Required | Type | Notes |
|
||||
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------- |
|
||||
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
|
||||
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
|
||||
| `unit` | yes | string | The canonical unit for the type (see the table). |
|
||||
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
|
||||
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
|
||||
| `notes` | no | string | Optional free text. |
|
||||
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE` (e.g. fasting / post-meal). |
|
||||
|
||||
### Mood entry
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
|---|---|---|---|
|
||||
| `date` | yes | string | `YYYY-MM-DD`. |
|
||||
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
|
||||
| `score` | yes | integer | `1`–`5`. |
|
||||
| `tags` | no | string | Comma-separated tags. |
|
||||
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
| Field | Required | Type | Notes |
|
||||
| ------------ | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `date` | yes | string | `YYYY-MM-DD`. |
|
||||
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
|
||||
| `score` | yes | integer | `1`–`5`. |
|
||||
| `tags` | no | string | Comma-separated tags. |
|
||||
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
|
||||
### `MeasurementType` values and canonical units
|
||||
|
||||
@@ -76,72 +80,72 @@ display per the user's preference. Event-class types record a single
|
||||
fired event (`value` is `1`) and are normally produced by a device, not
|
||||
hand-authored.
|
||||
|
||||
| Type | Canonical unit |
|
||||
|---|---|
|
||||
| `WEIGHT` | kg |
|
||||
| `BLOOD_PRESSURE_SYS` | mmHg |
|
||||
| `BLOOD_PRESSURE_DIA` | mmHg |
|
||||
| `PULSE` | bpm |
|
||||
| `RESTING_HEART_RATE` | bpm |
|
||||
| `WALKING_HEART_RATE_AVERAGE` | bpm |
|
||||
| `AVERAGE_HEART_RATE` | bpm |
|
||||
| `MAX_HEART_RATE` | bpm |
|
||||
| `CARDIO_RECOVERY` | bpm |
|
||||
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
|
||||
| `HRV_RMSSD` | ms (RMSSD) |
|
||||
| `RESPIRATORY_RATE` | count/min |
|
||||
| `OXYGEN_SATURATION` | percent (0–100) |
|
||||
| `BLOOD_GLUCOSE` | mg/dL |
|
||||
| `BODY_FAT` | percent (0–100) |
|
||||
| `FAT_MASS` | kg |
|
||||
| `FAT_FREE_MASS` | kg |
|
||||
| `LEAN_BODY_MASS` | kg |
|
||||
| `MUSCLE_MASS` | kg |
|
||||
| `BONE_MASS` | kg |
|
||||
| `TOTAL_BODY_WATER` | kg |
|
||||
| `VISCERAL_FAT` | rating |
|
||||
| `BODY_MASS_INDEX` | kg/m² |
|
||||
| `BODY_TEMPERATURE` | °C (core) |
|
||||
| `SKIN_TEMPERATURE` | °C (dermal spot) |
|
||||
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
|
||||
| `SLEEP_DURATION` | minutes |
|
||||
| `SLEEP_NEED` | minutes |
|
||||
| `SLEEP_PERFORMANCE` | percent (0–100) |
|
||||
| `SLEEP_EFFICIENCY` | percent (0–100) |
|
||||
| `SLEEP_CONSISTENCY` | percent (0–100) |
|
||||
| `SLEEP_DISTURBANCE_COUNT` | count |
|
||||
| `BREATHING_DISTURBANCES` | count |
|
||||
| `ACTIVITY_STEPS` | count |
|
||||
| `FLIGHTS_CLIMBED` | count |
|
||||
| `WALKING_RUNNING_DISTANCE` | metres |
|
||||
| `SIX_MINUTE_WALK_DISTANCE` | metres |
|
||||
| `WALKING_STEP_LENGTH` | metres |
|
||||
| `WALKING_SPEED` | m/s |
|
||||
| `STAIR_ASCENT_SPEED` | m/s |
|
||||
| `STAIR_DESCENT_SPEED` | m/s |
|
||||
| `WALKING_STEADINESS` | percent (0–100) |
|
||||
| `WALKING_ASYMMETRY` | percent (0–100) |
|
||||
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
|
||||
| `ACTIVE_ENERGY_BURNED` | kcal |
|
||||
| `ENERGY_EXPENDITURE_KJ` | kJ |
|
||||
| `VO2_MAX` | mL/(kg·min) |
|
||||
| `PULSE_WAVE_VELOCITY` | m/s |
|
||||
| `VASCULAR_AGE` | years |
|
||||
| `FALL_COUNT` | count |
|
||||
| `AUDIO_EXPOSURE_ENV` | dBA |
|
||||
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
|
||||
| `TIME_IN_DAYLIGHT` | minutes |
|
||||
| `RECOVERY_SCORE` | score (0–100) |
|
||||
| `STRESS_SCORE` | score (0–100) |
|
||||
| `STRAIN_SCORE` | score (0–100) |
|
||||
| `DAY_STRAIN` | score (0–21) |
|
||||
| `WORKOUT_STRAIN` | score (0–21) |
|
||||
| `AUDIO_EXPOSURE_EVENT` | count (event) |
|
||||
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
|
||||
| `HIGH_HEART_RATE_EVENT` | event |
|
||||
| `LOW_HEART_RATE_EVENT` | event |
|
||||
| `WALKING_STEADINESS_EVENT` | event |
|
||||
| `BREATHING_DISTURBANCE_EVENT` | event |
|
||||
| Type | Canonical unit |
|
||||
| ------------------------------- | -------------------- |
|
||||
| `WEIGHT` | kg |
|
||||
| `BLOOD_PRESSURE_SYS` | mmHg |
|
||||
| `BLOOD_PRESSURE_DIA` | mmHg |
|
||||
| `PULSE` | bpm |
|
||||
| `RESTING_HEART_RATE` | bpm |
|
||||
| `WALKING_HEART_RATE_AVERAGE` | bpm |
|
||||
| `AVERAGE_HEART_RATE` | bpm |
|
||||
| `MAX_HEART_RATE` | bpm |
|
||||
| `CARDIO_RECOVERY` | bpm |
|
||||
| `HEART_RATE_VARIABILITY` | ms (SDNN) |
|
||||
| `HRV_RMSSD` | ms (RMSSD) |
|
||||
| `RESPIRATORY_RATE` | count/min |
|
||||
| `OXYGEN_SATURATION` | percent (0–100) |
|
||||
| `BLOOD_GLUCOSE` | mg/dL |
|
||||
| `BODY_FAT` | percent (0–100) |
|
||||
| `FAT_MASS` | kg |
|
||||
| `FAT_FREE_MASS` | kg |
|
||||
| `LEAN_BODY_MASS` | kg |
|
||||
| `MUSCLE_MASS` | kg |
|
||||
| `BONE_MASS` | kg |
|
||||
| `TOTAL_BODY_WATER` | kg |
|
||||
| `VISCERAL_FAT` | rating |
|
||||
| `BODY_MASS_INDEX` | kg/m² |
|
||||
| `BODY_TEMPERATURE` | °C (core) |
|
||||
| `SKIN_TEMPERATURE` | °C (dermal spot) |
|
||||
| `WRIST_TEMPERATURE` | °C (overnight wrist) |
|
||||
| `SLEEP_DURATION` | minutes |
|
||||
| `SLEEP_NEED` | minutes |
|
||||
| `SLEEP_PERFORMANCE` | percent (0–100) |
|
||||
| `SLEEP_EFFICIENCY` | percent (0–100) |
|
||||
| `SLEEP_CONSISTENCY` | percent (0–100) |
|
||||
| `SLEEP_DISTURBANCE_COUNT` | count |
|
||||
| `BREATHING_DISTURBANCES` | count |
|
||||
| `ACTIVITY_STEPS` | count |
|
||||
| `FLIGHTS_CLIMBED` | count |
|
||||
| `WALKING_RUNNING_DISTANCE` | metres |
|
||||
| `SIX_MINUTE_WALK_DISTANCE` | metres |
|
||||
| `WALKING_STEP_LENGTH` | metres |
|
||||
| `WALKING_SPEED` | m/s |
|
||||
| `STAIR_ASCENT_SPEED` | m/s |
|
||||
| `STAIR_DESCENT_SPEED` | m/s |
|
||||
| `WALKING_STEADINESS` | percent (0–100) |
|
||||
| `WALKING_ASYMMETRY` | percent (0–100) |
|
||||
| `WALKING_DOUBLE_SUPPORT` | percent (0–100) |
|
||||
| `ACTIVE_ENERGY_BURNED` | kcal |
|
||||
| `ENERGY_EXPENDITURE_KJ` | kJ |
|
||||
| `VO2_MAX` | mL/(kg·min) |
|
||||
| `PULSE_WAVE_VELOCITY` | m/s |
|
||||
| `VASCULAR_AGE` | years |
|
||||
| `FALL_COUNT` | count |
|
||||
| `AUDIO_EXPOSURE_ENV` | dBA |
|
||||
| `AUDIO_EXPOSURE_HEADPHONE` | dBA |
|
||||
| `TIME_IN_DAYLIGHT` | minutes |
|
||||
| `RECOVERY_SCORE` | score (0–100) |
|
||||
| `STRESS_SCORE` | score (0–100) |
|
||||
| `STRAIN_SCORE` | score (0–100) |
|
||||
| `DAY_STRAIN` | score (0–21) |
|
||||
| `WORKOUT_STRAIN` | score (0–21) |
|
||||
| `AUDIO_EXPOSURE_EVENT` | count (event) |
|
||||
| `IRREGULAR_RHYTHM_NOTIFICATION` | event |
|
||||
| `HIGH_HEART_RATE_EVENT` | event |
|
||||
| `LOW_HEART_RATE_EVENT` | event |
|
||||
| `WALKING_STEADINESS_EVENT` | event |
|
||||
| `BREATHING_DISTURBANCE_EVENT` | event |
|
||||
|
||||
The live, machine-readable list of writable types and their units is
|
||||
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
|
||||
|
||||
@@ -31,6 +31,7 @@ grant scoped to credentials they control.
|
||||
and the connection dies when the first access token expires (~1 hour).
|
||||
The remaining `read:*` scopes are read-only; request all of them so the
|
||||
user grants once and every sync resource is covered.
|
||||
|
||||
4. Save the app. WHOOP issues a **Client ID** and **Client Secret** —
|
||||
keep the secret out of any chat log; it grants read access to the
|
||||
linked account's full WHOOP history.
|
||||
|
||||
@@ -11,14 +11,14 @@ relay.
|
||||
The Withings client (`src/lib/withings/client.ts`) maps the following
|
||||
metric types into `Measurement` rows tagged `source = WITHINGS`:
|
||||
|
||||
| Withings meastype | HealthLog `MeasurementType` |
|
||||
| ----------------- | --------------------------- |
|
||||
| 1 | `WEIGHT` |
|
||||
| 5 | `FAT_FREE_MASS` |
|
||||
| 9 | `BLOOD_PRESSURE_DIA` |
|
||||
| 10 | `BLOOD_PRESSURE_SYS` |
|
||||
| 11 | `PULSE` |
|
||||
| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) |
|
||||
| Withings meastype | HealthLog `MeasurementType` |
|
||||
| ---------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| 1 | `WEIGHT` |
|
||||
| 5 | `FAT_FREE_MASS` |
|
||||
| 9 | `BLOOD_PRESSURE_DIA` |
|
||||
| 10 | `BLOOD_PRESSURE_SYS` |
|
||||
| 11 | `PULSE` |
|
||||
| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) |
|
||||
| Plus the body-composition family (fat ratio, muscle mass, bone mass, hydration) and sleep / heart-rate variability via the heart-list endpoint |
|
||||
|
||||
Activity data (steps, active energy, distance, floors climbed) lands
|
||||
@@ -136,7 +136,7 @@ Override per-user via the Sources section of `/settings/thresholds`.
|
||||
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
|
||||
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
|
||||
are set in the container's runtime environment (`docker compose
|
||||
exec app env | grep WITHINGS`).
|
||||
exec app env | grep WITHINGS`).
|
||||
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
|
||||
must match an entry in the developer-portal app's callback list.
|
||||
Add the public URL exactly, including the `https://` scheme and
|
||||
@@ -183,7 +183,7 @@ The Settings page **Disconnect** button:
|
||||
the database — disconnect does not delete data. Re-linking the
|
||||
same Withings account merges back onto the same metric
|
||||
timeline; the upsert path matches on `(userId, type, measuredAt,
|
||||
source)` so previously-synced rows update in place.
|
||||
source)` so previously-synced rows update in place.
|
||||
|
||||
## Admin probe
|
||||
|
||||
|
||||
@@ -39,11 +39,11 @@ export without polluting the local shell.
|
||||
Output is one row per declared variable, grouped by section. Three
|
||||
states:
|
||||
|
||||
| Marker | Meaning |
|
||||
| --------------------- | ---------------------------------------------------------------------------------- |
|
||||
| `[OK]` | Variable is set and non-empty. |
|
||||
| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. |
|
||||
| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
|
||||
| Marker | Meaning |
|
||||
| -------------------- | -------------------------------------------------------------- |
|
||||
| `[OK]` | Variable is set and non-empty. |
|
||||
| `[MISSING-REQUIRED]` | Variable is required for boot. The script exits 1. |
|
||||
| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
|
||||
|
||||
The grep-friendly format lets a CI pipeline do `grep -q
|
||||
'\[MISSING-REQUIRED\]'` to gate a deploy. The CI integration itself
|
||||
@@ -93,7 +93,7 @@ so changes go through a PR. To add a new variable:
|
||||
Deferred to v1.4.43:
|
||||
|
||||
- GitHub Actions workflow that runs `pnpm check-env --file
|
||||
.env.production.example` on every PR, blocking changes that
|
||||
.env.production.example` on every PR, blocking changes that
|
||||
introduce new required vars without updating the example file.
|
||||
- Coolify pre-deploy hook that runs `pnpm check-env` inside the
|
||||
target container and aborts the deploy on exit code 1.
|
||||
@@ -96,6 +96,7 @@ pnpm dlx --package pg --package tsx tsx scripts/repair-intake-anomalies.ts --fix
|
||||
```
|
||||
|
||||
`--tombstone-implausible` is refused without `--fix`.
|
||||
|
||||
- **Drifted schedule windows are reconciled** to
|
||||
`min/max(times_of_day)` so the legacy pair stops contradicting the
|
||||
canonical dose times. Overnight windows (`window_end < window_start`)
|
||||
|
||||
@@ -9,8 +9,8 @@ already installed, longer if a TLS-fronted public hostname is in scope
|
||||
|
||||
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
|
||||
pulls a multi-arch image from GitHub Container Registry (linux/amd64
|
||||
+ linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
|
||||
unchanged.
|
||||
- linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
|
||||
unchanged.
|
||||
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
|
||||
measurement history; the Apple Health import worker can briefly
|
||||
hold a 1.5 GB upload in `/tmp` while it parses.
|
||||
@@ -43,11 +43,11 @@ echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------- | ------- | ------------- |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
|
||||
Then open `.env` and confirm `DATABASE_URL` includes the password you
|
||||
just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`)
|
||||
@@ -131,9 +131,9 @@ compose `environment:` block has no effect — they only take hold when
|
||||
you build your own image. They are intentionally absent from the
|
||||
compose runtime whitelist for that reason.
|
||||
|
||||
| Variable | Default | Effect when set |
|
||||
| --- | --- | --- |
|
||||
| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
|
||||
| Variable | Default | Effect when set |
|
||||
| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `NEXT_PUBLIC_DASHBOARD_SNAPSHOT` | on | The dashboard hydrates every above-the-fold tile from the single `GET /api/dashboard/snapshot` cell so the whole strip shares one completion moment. Build with `NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false` to fall back to the legacy four-cell path. |
|
||||
|
||||
To flip one, rebuild the image with the variable exported:
|
||||
|
||||
@@ -144,18 +144,18 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
|
||||
## 7. Where to go next
|
||||
|
||||
| Next step | File |
|
||||
| --------- | ---- |
|
||||
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
|
||||
| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` |
|
||||
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
|
||||
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
|
||||
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
|
||||
| Withings device sync | `docs/integrations/withings.md` |
|
||||
| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` |
|
||||
| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` |
|
||||
| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
|
||||
| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
|
||||
| Next step | File |
|
||||
| ------------------------------------------------------------------- | ------------------------------------- |
|
||||
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
|
||||
| Push notifications — Web Push, Telegram, ntfy, webhook, email, APNs | `docs/self-hosting/notifications.md` |
|
||||
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
|
||||
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
|
||||
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
|
||||
| Withings device sync | `docs/integrations/withings.md` |
|
||||
| WHOOP device sync (per-user BYO-keys) | `docs/integrations/whoop.md` |
|
||||
| Google Health sync — Fitbit & Pixel (per-user BYO-keys) | `docs/integrations/google-health.md` |
|
||||
| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
|
||||
| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -168,7 +168,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
`app_settings.singleton.registrationEnabled`. The admin panel can
|
||||
flip it back if a previous owner closed it.
|
||||
- **Service unreachable on `localhost:3000`.** Check `docker compose
|
||||
ps` — the `app` healthcheck polls `/api/version` every 30 seconds
|
||||
ps` — the `app` healthcheck polls `/api/version` every 30 seconds
|
||||
and will mark the container unhealthy if Postgres is still
|
||||
starting. Tail `docker compose logs db` to confirm Postgres came up
|
||||
cleanly.
|
||||
|
||||
@@ -24,14 +24,14 @@ Apple-platform exclusives the web cannot offer.
|
||||
|
||||
## TL;DR — which channel needs what
|
||||
|
||||
| Channel | What it needs | Cost | Where it works |
|
||||
| --- | --- | --- | --- |
|
||||
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) |
|
||||
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
|
||||
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
|
||||
| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
|
||||
| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
|
||||
| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
|
||||
| Channel | What it needs | Cost | Where it works |
|
||||
| ------------ | ------------------------------------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------- |
|
||||
| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after _Add to Home Screen_) |
|
||||
| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs |
|
||||
| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser |
|
||||
| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
|
||||
| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
|
||||
| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
|
||||
|
||||
Web Push is the recommended default — it needs no third-party account,
|
||||
just a keypair you generate yourself once.
|
||||
@@ -173,7 +173,7 @@ pub/sub push relay you can use hosted (`ntfy.sh`) or run yourself. You
|
||||
subscribe to a **topic** in the ntfy app (iOS/Android) or a browser, and
|
||||
HealthLog publishes to that topic.
|
||||
|
||||
1. Pick a hard-to-guess topic name (the topic *is* the access control on
|
||||
1. Pick a hard-to-guess topic name (the topic _is_ the access control on
|
||||
public `ntfy.sh` — anyone who knows the string can read it, so treat
|
||||
it like a secret).
|
||||
2. Subscribe to that topic in the ntfy app or at `https://ntfy.sh/<topic>`.
|
||||
@@ -268,14 +268,14 @@ under your own Apple identity. Everything below is that path.
|
||||
|
||||
The APNs env vars (`src/lib/notifications/senders/apns.ts`):
|
||||
|
||||
| Variable | Purpose |
|
||||
| --- | --- |
|
||||
| `APNS_KEY_ID` | The `.p8` key's Key ID |
|
||||
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
|
||||
| `APNS_BUNDLE_ID` | Your app's bundle identifier |
|
||||
| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
|
||||
| `APNS_KEY` | OR the raw PEM body inline |
|
||||
| `APNS_KEY_FILE` | OR a path to the `.p8` file |
|
||||
| Variable | Purpose |
|
||||
| --------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| `APNS_KEY_ID` | The `.p8` key's Key ID |
|
||||
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
|
||||
| `APNS_BUNDLE_ID` | Your app's bundle identifier |
|
||||
| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) |
|
||||
| `APNS_KEY` | OR the raw PEM body inline |
|
||||
| `APNS_KEY_FILE` | OR a path to the `.p8` file |
|
||||
| `APNS_CRITICAL_ENTITLEMENT` | Optional — set `true` only if Apple grants your app the Critical Alerts entitlement |
|
||||
|
||||
APNs is **all-or-none**: either set the three IDs plus exactly one key
|
||||
@@ -311,14 +311,14 @@ Two gotchas worth knowing before you blame a stale token:
|
||||
|
||||
## Which channel should I pick?
|
||||
|
||||
| Your setup | Recommended channel |
|
||||
| --- | --- |
|
||||
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
|
||||
| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
|
||||
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. |
|
||||
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
|
||||
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
|
||||
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
|
||||
| Your setup | Recommended channel |
|
||||
| ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. |
|
||||
| PWA on an iPhone | **Web Push**, but install the PWA via _Add to Home Screen_ first (iOS 16.4+). Reminders, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
|
||||
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with Web Push as a fallback for any non-iOS device. |
|
||||
| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. |
|
||||
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
|
||||
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
|
||||
|
||||
Mixing channels is fine and common — e.g. Web Push on the laptop and
|
||||
ntfy on the phone. Each user configures their own set from
|
||||
|
||||
@@ -45,7 +45,7 @@ reached:
|
||||
- **Physical or legal seizure of your own host.** If the machine
|
||||
running PostgreSQL — your VPS, your home server, your NAS — is seized,
|
||||
imaged, or compelled, the data is on it. Encryption at rest protects
|
||||
against a stolen disk or a leaked backup only while the key is *not*
|
||||
against a stolen disk or a leaked backup only while the key is _not_
|
||||
also available; if the host is running, or the key is stored next to
|
||||
the data, that protection is gone.
|
||||
- **Your hosting provider.** A VPS provider can snapshot your disk and
|
||||
@@ -77,7 +77,7 @@ record, not speculation:
|
||||
California privacy statute over the sharing of menstrual-tracking
|
||||
data; the verdict and the underlying class action are public record.
|
||||
|
||||
These are cited as factual public record to explain *why* the
|
||||
These are cited as factual public record to explain _why_ the
|
||||
"who holds the data" question is the one that matters for this category.
|
||||
HealthLog makes no claim about any other product's current practices.
|
||||
|
||||
|
||||
@@ -11,11 +11,11 @@ the env-var pairing is identical across them.
|
||||
|
||||
## Pre-flight — env vars the proxy interacts with
|
||||
|
||||
| Variable | Required for | Notes |
|
||||
| -------- | ------------ | ----- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
|
||||
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
|
||||
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
|
||||
| Variable | Required for | Notes |
|
||||
| --------------------- | ---------------------------------------------- | ---------------------------------------- |
|
||||
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
|
||||
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
|
||||
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
|
||||
|
||||
Set the URL pair to the public origin (`https://your-instance.example.com`)
|
||||
and restart the `app` container. The image reads both at startup; no
|
||||
@@ -28,12 +28,12 @@ chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
|
||||
This guards against a client rotating `X-Forwarded-For` per request
|
||||
to defeat IP-based rate limits.
|
||||
|
||||
| Topology | `TRUST_PROXY_HOPS` |
|
||||
| -------- | ------------------ |
|
||||
| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
|
||||
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
|
||||
| Cloudflare → your proxy → app | `2` |
|
||||
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
|
||||
| Topology | `TRUST_PROXY_HOPS` |
|
||||
| --------------------------------------------------------- | -------------------------- |
|
||||
| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
|
||||
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
|
||||
| Cloudflare → your proxy → app | `2` |
|
||||
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
|
||||
|
||||
A misconfigured count logs a one-shot warning to stderr and collapses
|
||||
every anonymous caller into one shared rate-limit bucket. Match the
|
||||
@@ -132,7 +132,7 @@ operator attention:
|
||||
|
||||
The following variables are **build-time only** — they are injected
|
||||
via `--build-arg` by the `docker-publish` workflow and baked into the
|
||||
shipped image. Setting them under Coolify's *Environment Variables*
|
||||
shipped image. Setting them under Coolify's _Environment Variables_
|
||||
panel has zero effect on the running container, and the stale value
|
||||
becomes a misleading artefact next time someone audits the deploy
|
||||
config:
|
||||
|
||||
@@ -90,7 +90,7 @@ services:
|
||||
app:
|
||||
image: ghcr.io/mbombeck/healthlog:latest
|
||||
environment:
|
||||
DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
|
||||
DATABASE_POOL_MAX: "12" # 6 containers × 12 = 72 < 100
|
||||
HEALTHLOG_PROCESS_TYPE: web
|
||||
```
|
||||
|
||||
|
||||
@@ -235,7 +235,7 @@ lists) uses the unified `<FilterBar>` rail from
|
||||
labelled `<Select>`s and date inputs.
|
||||
|
||||
- **Grammar**: `[icon] [Zeitraum] [Typ] [Quelle] [extras…]
|
||||
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
|
||||
[Zurücksetzen] … [count]`. Canonical dimension order is date range,
|
||||
type, source; surface-specific dimensions append after.
|
||||
- **Pill = trigger + chip**: inactive pills show only the dimension
|
||||
name; active pills show `Name: Wert`, pick up the primary accent and
|
||||
|
||||
@@ -243,7 +243,9 @@ test.describe("Coach guided clarifying questions", () => {
|
||||
await expect(current).toHaveCount(0);
|
||||
|
||||
// Settling the offer (decline via ✕) releases the next question.
|
||||
await adopt.locator('[data-slot="coach-self-context-adopt-dismiss"]').click();
|
||||
await adopt
|
||||
.locator('[data-slot="coach-self-context-adopt-dismiss"]')
|
||||
.click();
|
||||
await expect(current).toContainText(QUESTIONS[1], { timeout: 10_000 });
|
||||
await expect(
|
||||
current.locator('[data-slot="coach-guided-progress"]'),
|
||||
|
||||
@@ -114,7 +114,12 @@ test.describe("authenticated dashboard render", () => {
|
||||
const types = (url.searchParams.get("types") ?? "WEIGHT").split(",");
|
||||
const series: Record<
|
||||
string,
|
||||
Array<{ type: string; value: number; measuredAt: string; count: number }>
|
||||
Array<{
|
||||
type: string;
|
||||
value: number;
|
||||
measuredAt: string;
|
||||
count: number;
|
||||
}>
|
||||
> = {};
|
||||
for (const type of types) {
|
||||
const baseValue =
|
||||
|
||||
@@ -24,9 +24,9 @@ import { STORAGE_STATE_PATH } from "./setup/global-setup";
|
||||
test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
test.use({ storageState: STORAGE_STATE_PATH });
|
||||
|
||||
test.fixme(
|
||||
"returning to /insights from a sub-page lands at the top",
|
||||
async ({ page }) => {
|
||||
test.fixme("returning to /insights from a sub-page lands at the top", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/insights", { waitUntil: "domcontentloaded" });
|
||||
// Force the mother page tall enough that scrolling has somewhere
|
||||
// to go even on a sparse seed; the rendered Insights overview
|
||||
@@ -42,10 +42,7 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
await page.waitForURL(/\/insights\//);
|
||||
|
||||
// Back to the mother page via the overview pill (pill index 0).
|
||||
await page
|
||||
.locator("[data-slot='insights-tab-strip-pill']")
|
||||
.first()
|
||||
.click();
|
||||
await page.locator("[data-slot='insights-tab-strip-pill']").first().click();
|
||||
await page.waitForURL(/\/insights\/?$/);
|
||||
|
||||
// The mother page's mount-effect deferred a `scrollTo(0)` to the
|
||||
@@ -53,8 +50,7 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
await page.waitForTimeout(50);
|
||||
const y = await page.evaluate(() => window.scrollY);
|
||||
expect(y).toBeLessThan(50);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("the sticky tab strip declares touch-action pan-y so vertical swipes pass through", async ({
|
||||
page,
|
||||
@@ -63,7 +59,9 @@ test.describe("v1.4.28 — insights scroll restoration", () => {
|
||||
const nav = page.locator("[data-slot='insights-tab-strip']");
|
||||
await expect(nav).toBeVisible();
|
||||
const touchAction = await nav.evaluate(
|
||||
(el) => (el as HTMLElement).style.touchAction || getComputedStyle(el).touchAction,
|
||||
(el) =>
|
||||
(el as HTMLElement).style.touchAction ||
|
||||
getComputedStyle(el).touchAction,
|
||||
);
|
||||
expect(touchAction).toContain("pan-y");
|
||||
});
|
||||
|
||||
@@ -165,10 +165,7 @@ test.describe("medication wizard — compose-mode", () => {
|
||||
expect(capture.postBody).toMatchObject({
|
||||
name: "Insulin",
|
||||
oneShot: false,
|
||||
schedules: [
|
||||
{ rrule: "FREQ=DAILY" },
|
||||
{ rrule: "FREQ=WEEKLY;BYDAY=WE" },
|
||||
],
|
||||
schedules: [{ rrule: "FREQ=DAILY" }, { rrule: "FREQ=WEEKLY;BYDAY=WE" }],
|
||||
});
|
||||
|
||||
await expectMedicationOnList(page, {
|
||||
|
||||
@@ -221,9 +221,13 @@ export async function expectStep(
|
||||
// stepper landed, so it is hidden on the desktop e2e viewport. Assert the
|
||||
// viewport-independent root data-attributes the dialog always carries.
|
||||
const root = page.locator('[data-slot="medication-wizard-dialog"]');
|
||||
await expect(root).toHaveAttribute("data-display-step", String(displayIndex), {
|
||||
timeout: 5_000,
|
||||
});
|
||||
await expect(root).toHaveAttribute(
|
||||
"data-display-step",
|
||||
String(displayIndex),
|
||||
{
|
||||
timeout: 5_000,
|
||||
},
|
||||
);
|
||||
if (totalSteps != null) {
|
||||
await expect(root).toHaveAttribute("data-total-steps", String(totalSteps));
|
||||
}
|
||||
|
||||
@@ -159,7 +159,9 @@ test.describe("mobile-viewport smoke", () => {
|
||||
// trimmed text. A role-based label is more stable across
|
||||
// re-skins than the raw `innerText` that brittle selectors
|
||||
// surfaced previously.
|
||||
const ariaLabel = await btn.getAttribute("aria-label").catch(() => null);
|
||||
const ariaLabel = await btn
|
||||
.getAttribute("aria-label")
|
||||
.catch(() => null);
|
||||
const innerText = await btn.innerText().catch(() => "");
|
||||
const label = ariaLabel || innerText || "(no text)";
|
||||
failures.push(
|
||||
|
||||
@@ -138,17 +138,17 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
// CCH-04 — the per-metric inline launch icon is retired; no header
|
||||
// launcher renders on the sub-page.
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-launch-icon"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// The FAB is the permanent launcher; with no unread nudge it
|
||||
// renders without the unread dot.
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test(`/insights/${slug} mounts the dot-free Coach FAB on Desktop Chrome`, async ({
|
||||
@@ -161,15 +161,15 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
await page.goto(`/insights/${slug}`, { waitUntil: "domcontentloaded" });
|
||||
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-launch-icon"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="coach-launch-icon"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
const fab = page.locator('[data-slot="coach-fab"]');
|
||||
await expect(fab).toBeVisible({ timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toHaveCount(0);
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -255,9 +255,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
await expect(fab).toHaveAttribute("data-unread", "true", {
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-fab-unread"]'),
|
||||
).toBeVisible();
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toBeVisible();
|
||||
await fab.click();
|
||||
|
||||
// v1.16.11 — the FAB opens the side drawer in place instead of
|
||||
@@ -274,9 +272,7 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
// Opening the Coach counts as reading the nudge on this device —
|
||||
// the dot clears and the unread flag drops off the FAB.
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(
|
||||
0,
|
||||
);
|
||||
await expect(page.locator('[data-slot="coach-fab-unread"]')).toHaveCount(0);
|
||||
await expect(fab).not.toHaveAttribute("data-unread", "true");
|
||||
});
|
||||
|
||||
@@ -318,9 +314,9 @@ test.describe("Coach launch surfaces on insights sub-pages", () => {
|
||||
|
||||
// No in-panel tray opens — the click navigates to the full view.
|
||||
await page.waitForURL("**/coach", { timeout: 10_000 });
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-page"]'),
|
||||
).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.locator('[data-slot="coach-page"]')).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(
|
||||
page.locator('[data-slot="coach-drawer-history-tray"]'),
|
||||
).toHaveCount(0);
|
||||
|
||||
@@ -148,9 +148,7 @@ test.describe("v1.4.27 — insights empty-state with metric gating", () => {
|
||||
// title). Pin on the sub-page slot's h1 so we don't accidentally
|
||||
// match a sidebar / nav element.
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-slot="insights-subpage"] #insights-subpage-title',
|
||||
),
|
||||
page.locator('[data-slot="insights-subpage"] #insights-subpage-title'),
|
||||
).toContainText(headingMatch);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -38,14 +38,14 @@ test.describe("v1.4.27 — public pages", () => {
|
||||
// No bounce to the login page.
|
||||
expect(page.url()).toMatch(/\/about(\?|$)/);
|
||||
|
||||
await expect(page.getByRole("heading", { name: /about/i }).first()).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", { name: /about/i }).first(),
|
||||
).toBeVisible();
|
||||
|
||||
// GeoLite2 attribution is the load-bearing reason this page exists.
|
||||
await expect(page.getByText(/GeoLite2/)).toBeVisible();
|
||||
await expect(page.getByText(/MaxMind/)).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Attribution-ShareAlike 4\.0/i),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/Attribution-ShareAlike 4\.0/i)).toBeVisible();
|
||||
});
|
||||
|
||||
// v1.4.28 backlog: Next.js's `not-found.tsx` may return 200 or 404
|
||||
|
||||
@@ -58,9 +58,7 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const content = page.locator(
|
||||
'[data-slot="responsive-sheet-content"]',
|
||||
);
|
||||
const content = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(content).toBeVisible({ timeout: 10_000 });
|
||||
await expect(content).toHaveAttribute("data-variant", "sheet");
|
||||
|
||||
@@ -85,9 +83,7 @@ test.describe("v1.4.27 — ResponsiveSheet branch", () => {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
|
||||
const content = page.locator(
|
||||
'[data-slot="responsive-sheet-content"]',
|
||||
);
|
||||
const content = page.locator('[data-slot="responsive-sheet-content"]');
|
||||
await expect(content).toBeVisible({ timeout: 10_000 });
|
||||
await expect(content).toHaveAttribute("data-variant", "dialog");
|
||||
});
|
||||
|
||||
@@ -125,8 +125,8 @@ const queryKeyFactoryRule = {
|
||||
node.key.type === "Identifier"
|
||||
? node.key.name
|
||||
: node.key.type === "Literal"
|
||||
? node.key.value
|
||||
: null;
|
||||
? node.key.value
|
||||
: null;
|
||||
if (keyName !== "queryKey" && keyName !== "mutationKey") return;
|
||||
|
||||
context.report({
|
||||
|
||||
@@ -81,7 +81,7 @@ const safeFetchRequiredRule = {
|
||||
schema: [],
|
||||
messages: {
|
||||
rawFetch:
|
||||
"Raw fetch() bypasses the safeFetch wrapper's manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from \"@/lib/safe-fetch\" and call it instead.",
|
||||
'Raw fetch() bypasses the safeFetch wrapper\'s manual-redirect + timeout (and requirePublicHost) defences. Import { safeFetch } from "@/lib/safe-fetch" and call it instead.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
|
||||
@@ -63,21 +63,10 @@
|
||||
"testcontainers",
|
||||
"tw-animate-css"
|
||||
],
|
||||
"ignoreBinaries": [
|
||||
"docker",
|
||||
"docker-compose",
|
||||
"pg_isready",
|
||||
"tsx",
|
||||
"cosign"
|
||||
],
|
||||
"ignoreBinaries": ["docker", "docker-compose", "pg_isready", "tsx", "cosign"],
|
||||
"ignoreExportsUsedInFile": true,
|
||||
"ignoreIssues": {
|
||||
"src/components/ui/**": [
|
||||
"exports",
|
||||
"types"
|
||||
],
|
||||
"src/lib/validations/**": [
|
||||
"types"
|
||||
]
|
||||
"src/components/ui/**": ["exports", "types"],
|
||||
"src/lib/validations/**": ["types"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -140,9 +140,7 @@ const nextConfig: NextConfig = {
|
||||
return [
|
||||
{
|
||||
source: "/:path*",
|
||||
headers: [
|
||||
{ key: "Permissions-Policy", value: "unload=()" },
|
||||
],
|
||||
headers: [{ key: "Permissions-Policy", value: "unload=()" }],
|
||||
},
|
||||
{
|
||||
source: "/((?!api|_next).*)",
|
||||
|
||||
@@ -41,19 +41,25 @@
|
||||
"name": "Add measurement",
|
||||
"short_name": "Add",
|
||||
"url": "/measurements?add=WEIGHT&source=shortcut",
|
||||
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
"icons": [
|
||||
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Today",
|
||||
"short_name": "Today",
|
||||
"url": "/?source=shortcut",
|
||||
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
"icons": [
|
||||
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Medications",
|
||||
"short_name": "Meds",
|
||||
"url": "/medications?source=shortcut",
|
||||
"icons": [{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }]
|
||||
"icons": [
|
||||
{ "src": "/logo-192.png", "sizes": "192x192", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -9,11 +9,7 @@
|
||||
* `self.__APP_VERSION__` (falling back to a literal).
|
||||
*/
|
||||
import { execFileSync } from "node:child_process";
|
||||
import {
|
||||
existsSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
|
||||
|
||||
@@ -59,7 +59,9 @@ async function main(): Promise<void> {
|
||||
const dryRun = !args.confirm;
|
||||
|
||||
if (dryRun) {
|
||||
console.log("[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.");
|
||||
console.log(
|
||||
"[backfill-note] DRY RUN — no rows will be written. Pass --confirm to commit.",
|
||||
);
|
||||
} else {
|
||||
console.log("[backfill-note] COMMIT MODE — updating rows in place.");
|
||||
}
|
||||
@@ -92,7 +94,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`);
|
||||
console.log(
|
||||
`[backfill-note] ${updated} row${updated === 1 ? "" : "s"} ${dryRun ? "would be" : "were"} updated`,
|
||||
);
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
|
||||
@@ -66,12 +66,9 @@ async function main(): Promise<void> {
|
||||
for (const [index, user] of users.entries()) {
|
||||
const userStarted = Date.now();
|
||||
try {
|
||||
const { rowsUpserted, durationMs } = await recomputeUserRollups(
|
||||
user.id,
|
||||
{
|
||||
granularities: ALL_GRANULARITIES,
|
||||
},
|
||||
);
|
||||
const { rowsUpserted, durationMs } = await recomputeUserRollups(user.id, {
|
||||
granularities: ALL_GRANULARITIES,
|
||||
});
|
||||
totalRowsUpserted += rowsUpserted;
|
||||
console.log(
|
||||
`[${index + 1}/${users.length}] user=${user.username} ` +
|
||||
|
||||
@@ -158,11 +158,7 @@ export function checkEnv(
|
||||
// All-or-none groups: surface a synthetic row when the group is
|
||||
// partially populated. This catches the v1.4.40 AP-2 pattern even
|
||||
// when the group itself is `required: false`.
|
||||
if (
|
||||
group.allOrNone &&
|
||||
presentInGroup > 0 &&
|
||||
absentInGroup > 0
|
||||
) {
|
||||
if (group.allOrNone && presentInGroup > 0 && absentInGroup > 0) {
|
||||
results.push({
|
||||
group: group.name,
|
||||
variable: "<all-or-none>",
|
||||
@@ -205,8 +201,7 @@ function renderResults(results: CheckResult[]): {
|
||||
);
|
||||
} else if (r.required) {
|
||||
console.log(
|
||||
` [MISSING-REQUIRED] ${r.variable}` +
|
||||
(r.note ? ` — ${r.note}` : ""),
|
||||
` [MISSING-REQUIRED] ${r.variable}` + (r.note ? ` — ${r.note}` : ""),
|
||||
);
|
||||
missingRequired++;
|
||||
} else {
|
||||
|
||||
@@ -163,11 +163,17 @@
|
||||
"required": false,
|
||||
"allOrNone": true,
|
||||
"variables": [
|
||||
{ "name": "BACKUP_S3_ENDPOINT", "purpose": "S3-compatible endpoint URL." },
|
||||
{
|
||||
"name": "BACKUP_S3_ENDPOINT",
|
||||
"purpose": "S3-compatible endpoint URL."
|
||||
},
|
||||
{ "name": "BACKUP_S3_BUCKET", "purpose": "Bucket name." },
|
||||
{ "name": "BACKUP_S3_ACCESS_KEY", "purpose": "S3 access key." },
|
||||
{ "name": "BACKUP_S3_SECRET_KEY", "purpose": "S3 secret key." },
|
||||
{ "name": "BACKUP_ENCRYPTION_KEY", "purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY." }
|
||||
{
|
||||
"name": "BACKUP_ENCRYPTION_KEY",
|
||||
"purpose": "Separate AES key for the backup body — MUST NOT match ENCRYPTION_KEY."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -53,4 +53,6 @@ const rewritten = sw.replace(fallbackPattern, `$1${JSON.stringify(version)}`);
|
||||
if (rewritten !== sw) writeFileSync(swPath, rewritten, "utf8");
|
||||
|
||||
// One-line log so the build output shows the version we baked in.
|
||||
process.stdout.write(`sw-version.js → ${version} (sw.js fallback re-anchored)\n`);
|
||||
process.stdout.write(
|
||||
`sw-version.js → ${version} (sw.js fallback re-anchored)\n`,
|
||||
);
|
||||
@@ -30,28 +30,28 @@ on both ends** so secret columns can be NULLed and column order is pinned.
|
||||
|
||||
## What moves (FK-parent-first, 20 tables)
|
||||
|
||||
| # | Table | Scope predicate |
|
||||
|---|---|---|
|
||||
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
|
||||
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
|
||||
| 3 | `cycle_profiles` | `user_id = demo` |
|
||||
| 4 | `menstrual_cycles` | `user_id = demo` |
|
||||
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
|
||||
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
|
||||
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
|
||||
| 9 | `medications` | `user_id = demo` |
|
||||
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
|
||||
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
|
||||
| 13 | `medication_inventory_items` | `user_id = demo` |
|
||||
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
|
||||
| 15 | `medication_side_effects` | `user_id = demo` |
|
||||
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
|
||||
| 17 | `personal_records` | `user_id = demo` |
|
||||
| 18 | `user_achievements` | `user_id = demo` |
|
||||
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
|
||||
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
|
||||
| # | Table | Scope predicate |
|
||||
| --- | ----------------------------- | --------------------------------------------------------------------------------- |
|
||||
| 1 | `users` | `id = demo` (the one row; secrets NULLed, `role='ADMIN'`) |
|
||||
| 2 | `measurements` | `user_id = demo AND deleted_at IS NULL` (incl. RECOVERY/STRAIN/STRESS_SCORE rows) |
|
||||
| 3 | `cycle_profiles` | `user_id = demo` |
|
||||
| 4 | `menstrual_cycles` | `user_id = demo` |
|
||||
| 5 | `cycle_day_logs` | `user_id = demo` (encrypted cols NULLed) |
|
||||
| 6 | `cycle_symptom_links` | `day_log_id IN (demo's day logs)` |
|
||||
| 7 | `mood_entries` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 8 | `mood_entry_tag_links` | `mood_entry_id IN (demo's entries)` — global tags only |
|
||||
| 9 | `medications` | `user_id = demo` |
|
||||
| 10 | `medication_schedules` | `medication_id IN (demo's meds)` |
|
||||
| 11 | `medication_intake_events` | `user_id = demo AND deleted_at IS NULL` |
|
||||
| 12 | `medication_dose_changes` | `medication_id IN (demo's meds)` |
|
||||
| 13 | `medication_inventory_items` | `user_id = demo` |
|
||||
| 14 | `medication_inventory_events` | `medication_id IN (demo's meds)` |
|
||||
| 15 | `medication_side_effects` | `user_id = demo` |
|
||||
| 16 | `reminder_phase_configs` | `medication_id IN (demo's meds)` |
|
||||
| 17 | `personal_records` | `user_id = demo` |
|
||||
| 18 | `user_achievements` | `user_id = demo` |
|
||||
| 19 | `consent_receipts` | `user_id = demo` (carries the `ai_full` consent) |
|
||||
| 20 | `audit_logs` | `user_id = demo AND action LIKE 'insights.%'` (plaintext AI assessments) |
|
||||
|
||||
## Secret / credential columns NULLed on the `users` row
|
||||
|
||||
@@ -142,7 +142,7 @@ be a write-capable admin. Keep DEMO_MODE enabled.
|
||||
|
||||
## Caveats / things to confirm before running
|
||||
|
||||
- **`FOREIGN_HITS` assertion** counts *all* non-demo rows in the carried
|
||||
- **`FOREIGN_HITS` assertion** counts _all_ non-demo rows in the carried
|
||||
tables on edge01, not just what this run inserted. On a clean
|
||||
single-demo-user DB it is `0`. If edge01 legitimately hosts other demo-only
|
||||
accounts, treat a non-zero value as a warning to inspect, not an automatic
|
||||
|
||||
@@ -151,7 +151,11 @@ async function updateCoolifyEnv(
|
||||
appUuid: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
const list = await coolifyRequest(base, token, `/applications/${appUuid}/envs`);
|
||||
const list = await coolifyRequest(
|
||||
base,
|
||||
token,
|
||||
`/applications/${appUuid}/envs`,
|
||||
);
|
||||
if (!list.ok) {
|
||||
fail(`Coolify env list failed: HTTP ${list.status} — check token/UUID`);
|
||||
}
|
||||
|
||||
@@ -100,7 +100,10 @@ const TAG = "[repair-intake-anomalies]";
|
||||
// LOCAL time by default; this parser pins them back to UTC so every
|
||||
// Date this script handles is the true instant. (OID 1114 = timestamp
|
||||
// without time zone.)
|
||||
types.setTypeParser(1114, (value: string) => new Date(`${value.replace(" ", "T")}Z`));
|
||||
types.setTypeParser(
|
||||
1114,
|
||||
(value: string) => new Date(`${value.replace(" ", "T")}Z`),
|
||||
);
|
||||
|
||||
/**
|
||||
* Serialise a Date for a `timestamp without time zone` comparison /
|
||||
@@ -1056,11 +1059,7 @@ async function main(): Promise<void> {
|
||||
SET "starts_on" = $2::timestamp, "updated_at" = NOW()
|
||||
WHERE "id" = $1 AND "starts_on" = $3::timestamp
|
||||
`,
|
||||
[
|
||||
p.medicationId,
|
||||
utcParam(p.firstRow),
|
||||
utcParam(p.startsOn),
|
||||
],
|
||||
[p.medicationId, utcParam(p.firstRow), utcParam(p.startsOn)],
|
||||
);
|
||||
summary.startsOnAdjusted += res.rowCount ?? 0;
|
||||
}
|
||||
@@ -1105,11 +1104,17 @@ async function main(): Promise<void> {
|
||||
console.log(` duplicate slot groups: ${summary.duplicateGroups}`);
|
||||
console.log(` duplicate rows tombstoned: ${summary.rowsTombstoned}`);
|
||||
console.log(` implausible taken_at rows: ${summary.implausibleRows}`);
|
||||
console.log(` implausible rows tombstoned: ${summary.implausibleTombstoned}`);
|
||||
console.log(` window/times drift schedules: ${summary.windowDriftSchedules}`);
|
||||
console.log(
|
||||
` implausible rows tombstoned: ${summary.implausibleTombstoned}`,
|
||||
);
|
||||
console.log(
|
||||
` window/times drift schedules: ${summary.windowDriftSchedules}`,
|
||||
);
|
||||
console.log(` windows reconciled: ${summary.windowsReconciled}`);
|
||||
console.log(` stale-anchor pending rows: ${summary.staleAnchorRows}`);
|
||||
console.log(` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`);
|
||||
console.log(
|
||||
` stale-anchor rows tombstoned: ${summary.staleAnchorTombstoned}`,
|
||||
);
|
||||
console.log(` era-inference candidates: ${summary.eraCandidates}`);
|
||||
console.log(` era revisions created: ${summary.eraRevisionsCreated}`);
|
||||
console.log(` startsOn candidates: ${summary.startsOnCandidates}`);
|
||||
|
||||
@@ -207,7 +207,11 @@ async function main() {
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateStringColumn("WhoopConnection", field, prisma.whoopConnection),
|
||||
await rotateStringColumn(
|
||||
"WhoopConnection",
|
||||
field,
|
||||
prisma.whoopConnection,
|
||||
),
|
||||
);
|
||||
results.push(
|
||||
await rotateStringColumn(
|
||||
@@ -349,11 +353,7 @@ async function main() {
|
||||
await rotateBytesColumn("LabResult", "noteEncrypted", prisma.labResult),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
"Biomarker",
|
||||
"contextEncrypted",
|
||||
prisma.biomarker,
|
||||
),
|
||||
await rotateBytesColumn("Biomarker", "contextEncrypted", prisma.biomarker),
|
||||
);
|
||||
results.push(
|
||||
await rotateBytesColumn(
|
||||
|
||||
+189
-34
@@ -194,17 +194,41 @@ async function seed() {
|
||||
// Weight: 86.5 → trending down to a stable ~82 (healthy BMI ~24.7 at 182cm)
|
||||
const weights = randomWalk(86.5, 82.0, span, 0.6);
|
||||
// Systolic BP: 128 → settled in the optimal band ~118 (clamped)
|
||||
const sysBP = randomWalk(128, 118, span, 3, VALUE_RANGES.BLOOD_PRESSURE_SYS);
|
||||
const sysBP = randomWalk(
|
||||
128,
|
||||
118,
|
||||
span,
|
||||
3,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_SYS,
|
||||
);
|
||||
// Diastolic BP: 82 → settled ~76 (clamped)
|
||||
const diaBP = randomWalk(82, 76, span, 2.5, VALUE_RANGES.BLOOD_PRESSURE_DIA);
|
||||
const diaBP = randomWalk(
|
||||
82,
|
||||
76,
|
||||
span,
|
||||
2.5,
|
||||
VALUE_RANGES.BLOOD_PRESSURE_DIA,
|
||||
);
|
||||
// Spot pulse (daytime heart rate): ~72, gently lower
|
||||
const pulse = randomWalk(74, 70, span, 3, VALUE_RANGES.PULSE);
|
||||
// Resting heart rate: clean ~60, the metric the resting-pulse tile scores
|
||||
const restingHr = randomWalk(64, 58, span, 2, VALUE_RANGES.RESTING_HEART_RATE);
|
||||
const restingHr = randomWalk(
|
||||
64,
|
||||
58,
|
||||
span,
|
||||
2,
|
||||
VALUE_RANGES.RESTING_HEART_RATE,
|
||||
);
|
||||
// Body fat: 24% → trending to a healthy ~19%
|
||||
const bodyFat = randomWalk(24.0, 19.0, span, 0.4, VALUE_RANGES.BODY_FAT);
|
||||
// Sleep duration in MINUTES (the SLEEP_DURATION unit): ~7h → ~7h45m
|
||||
const sleepMin = randomWalk(420, 465, span, 25, VALUE_RANGES.SLEEP_DURATION);
|
||||
const sleepMin = randomWalk(
|
||||
420,
|
||||
465,
|
||||
span,
|
||||
25,
|
||||
VALUE_RANGES.SLEEP_DURATION,
|
||||
);
|
||||
// Steps: 6500 → a solid ~9000
|
||||
const steps = randomWalk(6500, 9000, span, 1500);
|
||||
|
||||
@@ -330,7 +354,8 @@ async function seed() {
|
||||
const leanMass = fatFree;
|
||||
// Total body water ≈ 73% of fat-free mass (kg of water).
|
||||
const bodyWater = Math.round(fatFree * 0.73 * 10) / 10;
|
||||
const boneMass = Math.round((3.1 + (Math.random() - 0.5) * 0.2) * 10) / 10;
|
||||
const boneMass =
|
||||
Math.round((3.1 + (Math.random() - 0.5) * 0.2) * 10) / 10;
|
||||
// Visceral-fat rating on Withings' 1–12 scale; a healthy ~6, easing
|
||||
// down slightly as the body fat trends down.
|
||||
const visceralFat = Math.round(7 - (i / span) * 1.5);
|
||||
@@ -396,29 +421,77 @@ async function seed() {
|
||||
// FLIGHTS_CLIMBED count. Source APPLE_HEALTH device_type 'watch'.
|
||||
console.log("Creating cardio fitness + vitals series...");
|
||||
// HRV SDNN: a healthy ~55 ms, gently rising; also write the RMSSD variant.
|
||||
const hrvSdnn = randomWalk(48, 58, span, 5, VALUE_RANGES.HEART_RATE_VARIABILITY);
|
||||
const hrvSdnn = randomWalk(
|
||||
48,
|
||||
58,
|
||||
span,
|
||||
5,
|
||||
VALUE_RANGES.HEART_RATE_VARIABILITY,
|
||||
);
|
||||
const hrvRmssd = randomWalk(42, 52, span, 5, VALUE_RANGES.HRV_RMSSD);
|
||||
// SpO2: 97–99%.
|
||||
const spo2 = randomWalk(98, 98, span, 0.8, VALUE_RANGES.OXYGEN_SATURATION);
|
||||
// Respiratory rate: 13–16 breaths/min.
|
||||
const respRate = randomWalk(15, 14, span, 0.8, VALUE_RANGES.RESPIRATORY_RATE);
|
||||
const respRate = randomWalk(
|
||||
15,
|
||||
14,
|
||||
span,
|
||||
0.8,
|
||||
VALUE_RANGES.RESPIRATORY_RATE,
|
||||
);
|
||||
// Active energy: ~550 kcal/day.
|
||||
const activeKcal = randomWalk(480, 600, span, 80, VALUE_RANGES.ACTIVE_ENERGY_BURNED);
|
||||
const activeKcal = randomWalk(
|
||||
480,
|
||||
600,
|
||||
span,
|
||||
80,
|
||||
VALUE_RANGES.ACTIVE_ENERGY_BURNED,
|
||||
);
|
||||
// Walking + running distance (metres): ~6–8 km/day, tracks steps.
|
||||
const distM = randomWalk(6200, 7800, span, 900, VALUE_RANGES.WALKING_RUNNING_DISTANCE);
|
||||
const distM = randomWalk(
|
||||
6200,
|
||||
7800,
|
||||
span,
|
||||
900,
|
||||
VALUE_RANGES.WALKING_RUNNING_DISTANCE,
|
||||
);
|
||||
// Flights climbed: ~8–14/day.
|
||||
const flights = randomWalk(9, 13, span, 4, VALUE_RANGES.FLIGHTS_CLIMBED);
|
||||
|
||||
for (let i = 0; i < span; i++) {
|
||||
const date = daysAgo(days - i);
|
||||
const vitals: Array<{ type: string; value: number; unit: string }> = [
|
||||
{ type: "HEART_RATE_VARIABILITY", value: Math.round(hrvSdnn[i]), unit: "ms" },
|
||||
{
|
||||
type: "HEART_RATE_VARIABILITY",
|
||||
value: Math.round(hrvSdnn[i]),
|
||||
unit: "ms",
|
||||
},
|
||||
{ type: "HRV_RMSSD", value: Math.round(hrvRmssd[i]), unit: "ms" },
|
||||
{ type: "OXYGEN_SATURATION", value: Math.round(spo2[i] * 10) / 10, unit: "%" },
|
||||
{ type: "RESPIRATORY_RATE", value: Math.round(respRate[i] * 10) / 10, unit: "count/min" },
|
||||
{ type: "ACTIVE_ENERGY_BURNED", value: Math.round(activeKcal[i]), unit: "kcal" },
|
||||
{ type: "WALKING_RUNNING_DISTANCE", value: Math.round(distM[i]), unit: "m" },
|
||||
{ type: "FLIGHTS_CLIMBED", value: Math.round(flights[i]), unit: "count" },
|
||||
{
|
||||
type: "OXYGEN_SATURATION",
|
||||
value: Math.round(spo2[i] * 10) / 10,
|
||||
unit: "%",
|
||||
},
|
||||
{
|
||||
type: "RESPIRATORY_RATE",
|
||||
value: Math.round(respRate[i] * 10) / 10,
|
||||
unit: "count/min",
|
||||
},
|
||||
{
|
||||
type: "ACTIVE_ENERGY_BURNED",
|
||||
value: Math.round(activeKcal[i]),
|
||||
unit: "kcal",
|
||||
},
|
||||
{
|
||||
type: "WALKING_RUNNING_DISTANCE",
|
||||
value: Math.round(distM[i]),
|
||||
unit: "m",
|
||||
},
|
||||
{
|
||||
type: "FLIGHTS_CLIMBED",
|
||||
value: Math.round(flights[i]),
|
||||
unit: "count",
|
||||
},
|
||||
];
|
||||
for (const v of vitals) {
|
||||
await client.query(
|
||||
@@ -449,19 +522,47 @@ async function seed() {
|
||||
console.log("Creating recovery / strain / sleep scores...");
|
||||
const recovery = randomWalk(68, 78, span, 8, VALUE_RANGES.RECOVERY_SCORE);
|
||||
const dayStrain = randomWalk(11, 13, span, 2.5, VALUE_RANGES.DAY_STRAIN);
|
||||
const sleepPerf = randomWalk(82, 90, span, 6, VALUE_RANGES.SLEEP_PERFORMANCE);
|
||||
const sleepPerf = randomWalk(
|
||||
82,
|
||||
90,
|
||||
span,
|
||||
6,
|
||||
VALUE_RANGES.SLEEP_PERFORMANCE,
|
||||
);
|
||||
const sleepEff = randomWalk(88, 93, span, 4, VALUE_RANGES.SLEEP_EFFICIENCY);
|
||||
const sleepConsistency = randomWalk(74, 84, span, 6, VALUE_RANGES.SLEEP_CONSISTENCY);
|
||||
const sleepConsistency = randomWalk(
|
||||
74,
|
||||
84,
|
||||
span,
|
||||
6,
|
||||
VALUE_RANGES.SLEEP_CONSISTENCY,
|
||||
);
|
||||
for (let i = 0; i < span; i++) {
|
||||
// Anchor scores to the morning wake instant so a night's recovery sits
|
||||
// on the day it belongs to.
|
||||
const at = daysAgoAt(days - i, 7, 0);
|
||||
const scores: Array<{ type: string; value: number; unit: string }> = [
|
||||
{ type: "RECOVERY_SCORE", value: Math.round(recovery[i]), unit: "score" },
|
||||
{ type: "DAY_STRAIN", value: Math.round(dayStrain[i] * 10) / 10, unit: "score" },
|
||||
{ type: "SLEEP_PERFORMANCE", value: Math.round(sleepPerf[i]), unit: "%" },
|
||||
{
|
||||
type: "RECOVERY_SCORE",
|
||||
value: Math.round(recovery[i]),
|
||||
unit: "score",
|
||||
},
|
||||
{
|
||||
type: "DAY_STRAIN",
|
||||
value: Math.round(dayStrain[i] * 10) / 10,
|
||||
unit: "score",
|
||||
},
|
||||
{
|
||||
type: "SLEEP_PERFORMANCE",
|
||||
value: Math.round(sleepPerf[i]),
|
||||
unit: "%",
|
||||
},
|
||||
{ type: "SLEEP_EFFICIENCY", value: Math.round(sleepEff[i]), unit: "%" },
|
||||
{ type: "SLEEP_CONSISTENCY", value: Math.round(sleepConsistency[i]), unit: "%" },
|
||||
{
|
||||
type: "SLEEP_CONSISTENCY",
|
||||
value: Math.round(sleepConsistency[i]),
|
||||
unit: "%",
|
||||
},
|
||||
];
|
||||
for (const s of scores) {
|
||||
await client.query(
|
||||
@@ -490,9 +591,33 @@ async function seed() {
|
||||
distanceM: number | null;
|
||||
strain: number;
|
||||
}> = [
|
||||
{ sport: "running", durationMin: 38, kcal: 420, avgHr: 148, maxHr: 171, distanceM: 6500, strain: 13.4 },
|
||||
{ sport: "strength", durationMin: 52, kcal: 360, avgHr: 118, maxHr: 152, distanceM: null, strain: 10.2 },
|
||||
{ sport: "cycling", durationMin: 65, kcal: 520, avgHr: 134, maxHr: 158, distanceM: 24000, strain: 12.1 },
|
||||
{
|
||||
sport: "running",
|
||||
durationMin: 38,
|
||||
kcal: 420,
|
||||
avgHr: 148,
|
||||
maxHr: 171,
|
||||
distanceM: 6500,
|
||||
strain: 13.4,
|
||||
},
|
||||
{
|
||||
sport: "strength",
|
||||
durationMin: 52,
|
||||
kcal: 360,
|
||||
avgHr: 118,
|
||||
maxHr: 152,
|
||||
distanceM: null,
|
||||
strain: 10.2,
|
||||
},
|
||||
{
|
||||
sport: "cycling",
|
||||
durationMin: 65,
|
||||
kcal: 520,
|
||||
avgHr: 134,
|
||||
maxHr: 158,
|
||||
distanceM: 24000,
|
||||
strain: 12.1,
|
||||
},
|
||||
];
|
||||
// Step backwards through the window placing ~2 sessions a week per slot
|
||||
// so the cadence lands around 3–4/week without overlapping.
|
||||
@@ -503,7 +628,11 @@ async function seed() {
|
||||
const plan = workoutPlan[planIdx % workoutPlan.length];
|
||||
planIdx += 1;
|
||||
const startHour = 17 + Math.floor(Math.random() * 3);
|
||||
const startedAt = daysAgoAt(dayOffset, startHour, Math.floor(Math.random() * 50));
|
||||
const startedAt = daysAgoAt(
|
||||
dayOffset,
|
||||
startHour,
|
||||
Math.floor(Math.random() * 50),
|
||||
);
|
||||
const durationSec = plan.durationMin * 60;
|
||||
const endedAt = new Date(startedAt.getTime() + durationSec * 1000);
|
||||
const workoutId = cuid();
|
||||
@@ -755,12 +884,24 @@ async function seed() {
|
||||
low: number;
|
||||
high: number;
|
||||
}> = [
|
||||
{ analyte: "Total Cholesterol", value: 178, unit: "mg/dL", low: 0, high: 200 },
|
||||
{
|
||||
analyte: "Total Cholesterol",
|
||||
value: 178,
|
||||
unit: "mg/dL",
|
||||
low: 0,
|
||||
high: 200,
|
||||
},
|
||||
{ analyte: "LDL", value: 98, unit: "mg/dL", low: 0, high: 130 },
|
||||
{ analyte: "HDL", value: 58, unit: "mg/dL", low: 40, high: 100 },
|
||||
{ analyte: "Triglycerides", value: 92, unit: "mg/dL", low: 0, high: 150 },
|
||||
{ analyte: "HbA1c", value: 5.2, unit: "%", low: 4.0, high: 5.6 },
|
||||
{ analyte: "Fasting Glucose", value: 89, unit: "mg/dL", low: 70, high: 99 },
|
||||
{
|
||||
analyte: "Fasting Glucose",
|
||||
value: 89,
|
||||
unit: "mg/dL",
|
||||
low: 70,
|
||||
high: 99,
|
||||
},
|
||||
{ analyte: "Vitamin D", value: 42, unit: "ng/mL", low: 30, high: 100 },
|
||||
{ analyte: "TSH", value: 1.8, unit: "mIU/L", low: 0.4, high: 4.0 },
|
||||
{ analyte: "Ferritin", value: 120, unit: "ng/mL", low: 30, high: 400 },
|
||||
@@ -919,14 +1060,28 @@ async function seed() {
|
||||
await client.query("COMMIT");
|
||||
console.log("\nDemo data seeded successfully!");
|
||||
console.log(` User: demo / demo123demo123`);
|
||||
console.log(` Measurements: weight, BP, pulse, resting HR, body fat, steps, sleep`);
|
||||
console.log(` Body composition: fat/lean/muscle mass, water, bone, visceral fat, BMI`);
|
||||
console.log(` Blood glucose: ${glucoseReadings.length} readings/day (fasting + post-meal + bedtime)`);
|
||||
console.log(` Cardio + vitals: HRV (SDNN + RMSSD), SpO2, resp. rate, VO2max, active energy, distance, flights`);
|
||||
console.log(` Scores: recovery, day strain, sleep performance/efficiency/consistency`);
|
||||
console.log(` Sleep: 7 per-stage nights (4 stages each) + daily aggregate`);
|
||||
console.log(
|
||||
` Measurements: weight, BP, pulse, resting HR, body fat, steps, sleep`,
|
||||
);
|
||||
console.log(
|
||||
` Body composition: fat/lean/muscle mass, water, bone, visceral fat, BMI`,
|
||||
);
|
||||
console.log(
|
||||
` Blood glucose: ${glucoseReadings.length} readings/day (fasting + post-meal + bedtime)`,
|
||||
);
|
||||
console.log(
|
||||
` Cardio + vitals: HRV (SDNN + RMSSD), SpO2, resp. rate, VO2max, active energy, distance, flights`,
|
||||
);
|
||||
console.log(
|
||||
` Scores: recovery, day strain, sleep performance/efficiency/consistency`,
|
||||
);
|
||||
console.log(
|
||||
` Sleep: 7 per-stage nights (4 stages each) + daily aggregate`,
|
||||
);
|
||||
console.log(` Medications: 3 (high compliance, today on-track)`);
|
||||
console.log(` Workouts: ~3-4/week (running, strength, cycling) with HR samples`);
|
||||
console.log(
|
||||
` Workouts: ~3-4/week (running, strength, cycling) with HR samples`,
|
||||
);
|
||||
console.log(` Mood: ~${Math.round(span * 0.95)} entries`);
|
||||
console.log(` Vorsorge reminders: 2 (dental, annual physical)`);
|
||||
console.log(` Lab panel: ${labResults.length} biomarkers`);
|
||||
|
||||
@@ -64,9 +64,9 @@ vi.mock("next/headers", () => ({
|
||||
// AsyncLocalStorage `eventStorage.run` wiring intact (the
|
||||
// `apiHandler` opens an event scope inside `run()`).
|
||||
vi.mock("@/lib/logging/context", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/lib/logging/context")
|
||||
>("@/lib/logging/context");
|
||||
const actual = await vi.importActual<typeof import("@/lib/logging/context")>(
|
||||
"@/lib/logging/context",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(actual.annotate),
|
||||
@@ -153,24 +153,18 @@ beforeEach(async () => {
|
||||
// `clearAllMocks()` to wipe call history between tests; `vi.fn()` at
|
||||
// module scope only sets the initial implementation, and the slim
|
||||
// slice test must observe a resolved value not `undefined`.
|
||||
const { ensureUserRollupsFresh } = await import(
|
||||
"@/lib/rollups/measurement-rollups"
|
||||
);
|
||||
const { probeRollupCoverage } = await import(
|
||||
"@/lib/rollups/measurement-coverage"
|
||||
);
|
||||
const { computeBpInTargetFastPath } = await import(
|
||||
"@/lib/analytics/bp-in-target-fast-path"
|
||||
);
|
||||
const { computeUserHealthScoreFastPath } = await import(
|
||||
"@/lib/analytics/health-score-fast-path"
|
||||
);
|
||||
const { computeCorrelationHypothesesFastPath } = await import(
|
||||
"@/lib/analytics/correlations-fast-path"
|
||||
);
|
||||
const { computeSummariesSlice } = await import(
|
||||
"@/lib/analytics/summaries-slice"
|
||||
);
|
||||
const { ensureUserRollupsFresh } =
|
||||
await import("@/lib/rollups/measurement-rollups");
|
||||
const { probeRollupCoverage } =
|
||||
await import("@/lib/rollups/measurement-coverage");
|
||||
const { computeBpInTargetFastPath } =
|
||||
await import("@/lib/analytics/bp-in-target-fast-path");
|
||||
const { computeUserHealthScoreFastPath } =
|
||||
await import("@/lib/analytics/health-score-fast-path");
|
||||
const { computeCorrelationHypothesesFastPath } =
|
||||
await import("@/lib/analytics/correlations-fast-path");
|
||||
const { computeSummariesSlice } =
|
||||
await import("@/lib/analytics/summaries-slice");
|
||||
|
||||
(ensureUserRollupsFresh as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
undefined,
|
||||
@@ -185,9 +179,9 @@ beforeEach(async () => {
|
||||
priorMonth: null,
|
||||
priorYear: null,
|
||||
});
|
||||
(computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>).mockResolvedValue(
|
||||
null,
|
||||
);
|
||||
(
|
||||
computeUserHealthScoreFastPath as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue(null);
|
||||
(
|
||||
computeCorrelationHypothesesFastPath as ReturnType<typeof vi.fn>
|
||||
).mockResolvedValue({ hypotheses: [] });
|
||||
|
||||
@@ -62,10 +62,7 @@ vi.mock("next/headers", () => ({
|
||||
|
||||
import { POST } from "@/app/api/auth/check-user/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
interface CheckUserBody {
|
||||
data: {
|
||||
|
||||
@@ -27,12 +27,7 @@ const ROOT = join(__dirname, "../..");
|
||||
const SRC = join(ROOT, "src");
|
||||
const EN_BUNDLE_PATH = join(ROOT, "messages/en.json");
|
||||
|
||||
const SKIP_DIRS = new Set([
|
||||
"__tests__",
|
||||
"node_modules",
|
||||
".next",
|
||||
"generated",
|
||||
]);
|
||||
const SKIP_DIRS = new Set(["__tests__", "node_modules", ".next", "generated"]);
|
||||
|
||||
function walk(dir: string, out: string[] = []): string[] {
|
||||
for (const name of readdirSync(dir)) {
|
||||
@@ -48,8 +43,7 @@ function walk(dir: string, out: string[] = []): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
const KEY_PATTERN =
|
||||
/(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.]+)["']/g;
|
||||
const KEY_PATTERN = /(?<![a-zA-Z0-9_])t\(\s*["']([a-zA-Z][a-zA-Z0-9_.]+)["']/g;
|
||||
|
||||
interface CallSite {
|
||||
key: string;
|
||||
@@ -98,7 +92,10 @@ function extractKeys(): CallSite[] {
|
||||
inBlockComment = true;
|
||||
break;
|
||||
}
|
||||
line = line.slice(0, start) + " ".repeat(end - start + 2) + line.slice(end + 2);
|
||||
line =
|
||||
line.slice(0, start) +
|
||||
" ".repeat(end - start + 2) +
|
||||
line.slice(end + 2);
|
||||
}
|
||||
line = stripCommentLine(line);
|
||||
let m: RegExpExecArray | null;
|
||||
@@ -119,7 +116,11 @@ function resolveLeaf(obj: unknown, dotted: string): string | null {
|
||||
const parts = dotted.split(".");
|
||||
let cur: unknown = obj;
|
||||
for (const p of parts) {
|
||||
if (cur && typeof cur === "object" && p in (cur as Record<string, unknown>)) {
|
||||
if (
|
||||
cur &&
|
||||
typeof cur === "object" &&
|
||||
p in (cur as Record<string, unknown>)
|
||||
) {
|
||||
cur = (cur as Record<string, unknown>)[p];
|
||||
} else {
|
||||
return null;
|
||||
|
||||
@@ -30,11 +30,7 @@ function loadBundle(locale: string): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(join(MESSAGES, `${locale}.json`), "utf8"));
|
||||
}
|
||||
|
||||
function flatten(
|
||||
obj: unknown,
|
||||
prefix: string,
|
||||
out: Map<string, string>,
|
||||
): void {
|
||||
function flatten(obj: unknown, prefix: string, out: Map<string, string>): void {
|
||||
if (obj == null || typeof obj !== "object") return;
|
||||
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
||||
const key = prefix ? `${prefix}.${k}` : k;
|
||||
|
||||
@@ -186,7 +186,11 @@ function isReferenced(leaf: string, ref: Reference): boolean {
|
||||
}
|
||||
}
|
||||
for (const ck of ref.literalKeys) {
|
||||
if (base === ck || base.startsWith(`${ck}.`) || ck.startsWith(`${base}.`)) {
|
||||
if (
|
||||
base === ck ||
|
||||
base.startsWith(`${ck}.`) ||
|
||||
ck.startsWith(`${base}.`)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,7 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
|
||||
});
|
||||
|
||||
it("permits the chart-overlay-prefs PUT (above-chart display toggles)", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"),
|
||||
);
|
||||
const res = proxy(makeRequest("/api/dashboard/chart-overlay-prefs", "PUT"));
|
||||
expect(res.status).not.toBe(403);
|
||||
});
|
||||
|
||||
|
||||
@@ -324,7 +324,9 @@ describe("sw.js — trimCache", () => {
|
||||
store.keysCalls = 0;
|
||||
|
||||
const trimCache = (
|
||||
harness.context as { trimCache?: (name: string, max: number) => Promise<void> }
|
||||
harness.context as {
|
||||
trimCache?: (name: string, max: number) => Promise<void>;
|
||||
}
|
||||
).trimCache;
|
||||
expect(typeof trimCache).toBe("function");
|
||||
await trimCache!(CURRENT_PAGE_CACHE, 5);
|
||||
@@ -340,10 +342,7 @@ describe("sw.js — trimCache", () => {
|
||||
});
|
||||
|
||||
// ── push handler (v1.18.4: clear-on-taken tag close + app badge) ─────────────
|
||||
async function dispatchPush(
|
||||
harness: SwHarness,
|
||||
data: unknown,
|
||||
): Promise<void> {
|
||||
async function dispatchPush(harness: SwHarness, data: unknown): Promise<void> {
|
||||
let settled: Promise<unknown> = Promise.resolve();
|
||||
harness.listeners.get("push")!({
|
||||
data: { json: () => data, text: () => JSON.stringify(data) },
|
||||
|
||||
@@ -147,12 +147,8 @@ describe("dashboard chart-reveal wiring (page.tsx)", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
|
||||
it("derives the gated id list from the chart visibility gates", () => {
|
||||
expect(src).toMatch(
|
||||
/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/,
|
||||
);
|
||||
expect(src).toMatch(
|
||||
/useDashboardChartReveal\(revealChartIds\)/,
|
||||
);
|
||||
expect(src).toMatch(/const\s+revealChartIds:\s*string\[\]\s*=\s*\[\];/);
|
||||
expect(src).toMatch(/useDashboardChartReveal\(revealChartIds\)/);
|
||||
});
|
||||
|
||||
it("every data-backed chart reports through onDataReady", () => {
|
||||
|
||||
@@ -243,9 +243,7 @@ describe("v1.4.43 W11 — dashboard tile-strip skeleton (wiring)", () => {
|
||||
|
||||
it("uses the structured silhouette as the per-tile Suspense fallback", () => {
|
||||
const src = readFileSync(PAGE_PATH, "utf8");
|
||||
expect(src).toMatch(
|
||||
/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/,
|
||||
);
|
||||
expect(src).toMatch(/<Suspense fallback=\{<TrendCardSkeleton \/>\}>/);
|
||||
});
|
||||
|
||||
it("gates the EmptyState on the resolved `showEmptyState` flag", () => {
|
||||
|
||||
@@ -68,18 +68,14 @@ describe("v1.4.27 R3d MB4 — the layout mounts the Coach drawer", () => {
|
||||
const shellSource = load(
|
||||
`${process.cwd()}/src/components/layout/auth-shell.tsx`,
|
||||
);
|
||||
expect(shellSource).toContain(
|
||||
'from "@/lib/insights/coach-launch-context"',
|
||||
);
|
||||
expect(shellSource).toContain('from "@/lib/insights/coach-launch-context"');
|
||||
expect(shellSource).toMatch(/<CoachLaunchProvider>/);
|
||||
expect(shellSource).toMatch(/<LayoutCoachMount \/>/);
|
||||
});
|
||||
|
||||
it("the insights layout no longer double-mounts the provider or drawer", () => {
|
||||
const layout = load(INSIGHTS_LAYOUT_PATH);
|
||||
expect(layout).not.toContain(
|
||||
'from "@/lib/insights/coach-launch-context"',
|
||||
);
|
||||
expect(layout).not.toContain('from "@/lib/insights/coach-launch-context"');
|
||||
expect(layout).not.toContain("import { LayoutCoachMount }");
|
||||
// FAB stays scoped to `/insights/**` because the floating button
|
||||
// would distract on surfaces with a contextual inline CTA.
|
||||
|
||||
@@ -50,7 +50,7 @@ function Section({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section id={id} className="space-y-3 scroll-mt-28">
|
||||
<section id={id} className="scroll-mt-28 space-y-3">
|
||||
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
@@ -64,7 +64,7 @@ function Section({
|
||||
export default function AboutPage() {
|
||||
return (
|
||||
<div className="bg-background text-foreground min-h-dvh">
|
||||
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b backdrop-blur pt-[env(safe-area-inset-top)]">
|
||||
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b pt-[env(safe-area-inset-top)] backdrop-blur">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3 md:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
@@ -122,8 +122,8 @@ export default function AboutPage() {
|
||||
<Section id="credits" title="Credits">
|
||||
<p>
|
||||
HealthLog stands on a number of open-source libraries and public
|
||||
data sources. The list below covers the third-party assets that
|
||||
ship with the runtime image and whose licences require an explicit
|
||||
data sources. The list below covers the third-party assets that ship
|
||||
with the runtime image and whose licences require an explicit
|
||||
attribution.
|
||||
</p>
|
||||
|
||||
@@ -150,23 +150,22 @@ export default function AboutPage() {
|
||||
GeoLite2-ASN
|
||||
</code>
|
||||
) power the offline IP-to-location and IP-to-carrier lookups that
|
||||
decorate the admin login-overview audit table. They are
|
||||
distributed under the{" "}
|
||||
decorate the admin login-overview audit table. They are distributed
|
||||
under the{" "}
|
||||
<a
|
||||
href="https://creativecommons.org/licenses/by-sa/4.0/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Creative Commons Attribution-ShareAlike 4.0 International
|
||||
License
|
||||
Creative Commons Attribution-ShareAlike 4.0 International License
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<footer
|
||||
className="border-border/60 mt-12 border-t pt-6 text-xs text-muted-foreground"
|
||||
className="border-border/60 text-muted-foreground mt-12 border-t pt-6 text-xs"
|
||||
data-slot="about-footer"
|
||||
>
|
||||
<p>
|
||||
|
||||
@@ -222,11 +222,7 @@ function AchievementCard({ achievement, t }: AchievementCardProps) {
|
||||
value={achievement.progressPercent}
|
||||
className="h-1.5"
|
||||
aria-label={t("achievements.criterionHint", {
|
||||
current: formatMetric(
|
||||
achievement.format,
|
||||
achievement.current,
|
||||
t,
|
||||
),
|
||||
current: formatMetric(achievement.format, achievement.current, t),
|
||||
target: formatMetric(achievement.format, achievement.target, t),
|
||||
})}
|
||||
/>
|
||||
@@ -329,8 +325,7 @@ export default function AchievementsPage() {
|
||||
{t("achievements.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2"></div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
|
||||
@@ -268,7 +268,10 @@ function fileHasCall(text: string, needle: string): boolean {
|
||||
* parameterised insights routes resolve it from a metric map — those gate
|
||||
* dynamically and are accepted as gated without a literal-key assertion).
|
||||
*/
|
||||
function extractModuleKeys(text: string): { hasLiteral: boolean; keys: Set<string> } {
|
||||
function extractModuleKeys(text: string): {
|
||||
hasLiteral: boolean;
|
||||
keys: Set<string>;
|
||||
} {
|
||||
const keys = new Set<string>();
|
||||
let hasLiteral = false;
|
||||
const re = /requireModuleEnabled\([^,]+,\s*"([a-zA-Z]+)"\s*\)/g;
|
||||
|
||||
@@ -120,14 +120,14 @@ describe("GET /api/admin/ai-settings", () => {
|
||||
|
||||
describe("PUT /api/admin/ai-settings", () => {
|
||||
function upsertEcho() {
|
||||
vi.mocked(prisma.appSettings.upsert).mockImplementation(
|
||||
(async (args: { update: Record<string, unknown> }) => ({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
...args.update,
|
||||
})) as never,
|
||||
);
|
||||
vi.mocked(prisma.appSettings.upsert).mockImplementation((async (args: {
|
||||
update: Record<string, unknown>;
|
||||
}) => ({
|
||||
adminAiKeyEncrypted: null,
|
||||
adminAiModel: "gpt-4o",
|
||||
adminAiBaseUrl: "https://api.openai.com/v1",
|
||||
...args.update,
|
||||
})) as never);
|
||||
}
|
||||
|
||||
it("propagates the admin gate (non-admin cannot write)", async () => {
|
||||
|
||||
@@ -81,22 +81,28 @@ beforeEach(() => {
|
||||
describe("POST /api/admin/import-apple-health-export", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 403 when a non-admin cookie session calls the route", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER_SESSION as never);
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
@@ -107,11 +113,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
"content-type": "multipart/form-data; boundary=---x",
|
||||
"content-length": String(2 * 1024 * 1024 * 1024),
|
||||
});
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(413);
|
||||
});
|
||||
@@ -119,11 +128,14 @@ describe("POST /api/admin/import-apple-health-export", () => {
|
||||
it("returns 429 on rate-limit exhaustion", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_SESSION as never);
|
||||
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
|
||||
const req = new NextRequest("http://localhost/api/admin/import-apple-health-export", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
});
|
||||
const req = new NextRequest(
|
||||
"http://localhost/api/admin/import-apple-health-export",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "multipart/form-data; boundary=---x" },
|
||||
body: "stub",
|
||||
},
|
||||
);
|
||||
const res = await POST(req);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
|
||||
@@ -106,10 +106,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reason: "target_user_missing", targetUserId },
|
||||
});
|
||||
return apiError(
|
||||
`Target user '${targetUserId}' does not exist`,
|
||||
422,
|
||||
);
|
||||
return apiError(`Target user '${targetUserId}' does not exist`, 422);
|
||||
}
|
||||
|
||||
// Same content-hash idempotency as the user-facing route, but scoped
|
||||
|
||||
@@ -22,10 +22,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import {
|
||||
buildInviteUrl,
|
||||
generateInviteToken,
|
||||
} from "@/lib/auth/invite-token";
|
||||
import { buildInviteUrl, generateInviteToken } from "@/lib/auth/invite-token";
|
||||
import { inviteCreateSchema } from "@/lib/validations/invite";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
@@ -77,9 +74,7 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
|
||||
const { expiresInDays, maxUses } = parsed.data;
|
||||
const rawToken = generateInviteToken();
|
||||
const expiresAt = new Date(
|
||||
Date.now() + expiresInDays * 24 * 60 * 60 * 1000,
|
||||
);
|
||||
const expiresAt = new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const invite = await prisma.inviteToken.create({
|
||||
data: {
|
||||
|
||||
@@ -109,9 +109,7 @@ interface DiagnosticEnvelope {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
|
||||
});
|
||||
|
||||
|
||||
@@ -80,9 +80,10 @@ interface DiagnosticPayload {
|
||||
* (prefix / suffix both null) — that shape never appears in
|
||||
* production, but defensive against partial inserts during testing.
|
||||
*/
|
||||
function maskApnsToken(
|
||||
token: string | null,
|
||||
): { prefix: string | null; suffix: string | null } {
|
||||
function maskApnsToken(token: string | null): {
|
||||
prefix: string | null;
|
||||
suffix: string | null;
|
||||
} {
|
||||
if (!token || token.length < 16) return { prefix: null, suffix: null };
|
||||
return {
|
||||
prefix: token.slice(0, 8),
|
||||
|
||||
@@ -75,7 +75,13 @@ export const GET = apiHandler(async (request) => {
|
||||
for (const row of grouped) {
|
||||
const existing =
|
||||
byChannel.get(row.channel) ??
|
||||
({ channel: row.channel, ok: 0, error: 0, skipped: 0, total: 0 } satisfies ChannelHealthRow);
|
||||
({
|
||||
channel: row.channel,
|
||||
ok: 0,
|
||||
error: 0,
|
||||
skipped: 0,
|
||||
total: 0,
|
||||
} satisfies ChannelHealthRow);
|
||||
const count = row._count._all;
|
||||
if (row.result === "ok") existing.ok += count;
|
||||
else if (row.result === "error") existing.error += count;
|
||||
|
||||
@@ -36,8 +36,9 @@ vi.mock("@/lib/api-handler", async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(h: T): T =>
|
||||
h,
|
||||
apiHandler: <T extends (...args: unknown[]) => Promise<Response>>(
|
||||
h: T,
|
||||
): T => h,
|
||||
requireAdmin: vi.fn(),
|
||||
};
|
||||
});
|
||||
@@ -149,9 +150,7 @@ describe("POST /api/admin/notifications/test — APNS branch (v1.4.46)", () => {
|
||||
eventType: "SYSTEM_ALERT",
|
||||
}),
|
||||
);
|
||||
expect(body.data.results).toEqual([
|
||||
{ channel: "APNS", success: true },
|
||||
]);
|
||||
expect(body.data.results).toEqual([{ channel: "APNS", success: true }]);
|
||||
expect(body.data.sent).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -63,8 +63,14 @@ async function ensureTelegramChannel(userId: string): Promise<boolean> {
|
||||
}
|
||||
|
||||
function isLocale(value: string | null | undefined): value is Locale {
|
||||
return value === "de" || value === "en" || value === "fr" ||
|
||||
value === "es" || value === "it" || value === "pl";
|
||||
return (
|
||||
value === "de" ||
|
||||
value === "en" ||
|
||||
value === "fr" ||
|
||||
value === "es" ||
|
||||
value === "it" ||
|
||||
value === "pl"
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
|
||||
@@ -48,7 +48,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
@@ -56,7 +58,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
it("returns 403 when the caller is not an admin (cookie-only boundary)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(USER_OK as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
@@ -65,7 +69,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(ADMIN_OK as never);
|
||||
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
@@ -82,7 +88,9 @@ describe("GET /api/admin/settings/module-availability", () => {
|
||||
moduleAvailabilityJson: { mood: false },
|
||||
} as never);
|
||||
const res = await (GET as unknown as (r: NextRequest) => Promise<Response>)(
|
||||
new NextRequest("http://localhost/api/admin/settings/module-availability"),
|
||||
new NextRequest(
|
||||
"http://localhost/api/admin/settings/module-availability",
|
||||
),
|
||||
);
|
||||
const body = (await res.json()) as {
|
||||
data: { availability: Record<string, boolean> };
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
|
||||
import {
|
||||
apiSuccess,
|
||||
apiError,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { invalidateAppSettings } from "@/lib/cache/invalidate";
|
||||
@@ -68,7 +73,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const alreadyConfigured = Boolean(
|
||||
existing?.webPushVapidPublicKey &&
|
||||
existing?.webPushVapidPrivateKeyEncrypted,
|
||||
existing?.webPushVapidPrivateKeyEncrypted,
|
||||
);
|
||||
|
||||
if (alreadyConfigured && parsed.data.force !== true) {
|
||||
|
||||
@@ -170,5 +170,4 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
|
||||
});
|
||||
@@ -97,7 +97,10 @@ describe("PUT /api/admin/users/[id]", () => {
|
||||
|
||||
it("returns 404 when target user does not exist", async () => {
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
|
||||
const res = await PUT(jsonReq({ username: "testuser" }), params("u-missing"));
|
||||
const res = await PUT(
|
||||
jsonReq({ username: "testuser" }),
|
||||
params("u-missing"),
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
expect(vi.mocked(prisma.user.update)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -138,18 +138,14 @@ beforeEach(() => {
|
||||
// findMany + null findFirst means the slim slice's parity check
|
||||
// diverges and falls back to live SQL (preserves the pre-v1.4.35
|
||||
// assertions in this file).
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.measurementRollup.findFirst).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue(
|
||||
{ count: 0 } as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
vi.mocked(prisma.measurementRollup.deleteMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
vi.mocked(prisma.measurementRollup.upsert).mockResolvedValue({} as never);
|
||||
// v1.4.37 W2 — `ensureUserRollupsFresh` reads `measurement.findFirst`;
|
||||
// the per-type coverage probe + the rollup-recompute aggregator ride
|
||||
// `$queryRaw` / `$queryRawUnsafe`. Default to empty so the route
|
||||
@@ -318,12 +314,10 @@ describe("GET /api/analytics", () => {
|
||||
{ type: "WEIGHT", value: 81.8, measured_at: fiveDaysAgo },
|
||||
] as never);
|
||||
|
||||
const req = new Request(
|
||||
"http://localhost/api/analytics?slice=summaries",
|
||||
const req = new Request("http://localhost/api/analytics?slice=summaries");
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
req,
|
||||
);
|
||||
const res = await (
|
||||
GET as unknown as (req: Request) => Promise<Response>
|
||||
)(req);
|
||||
expect(res.status).toBe(200);
|
||||
// v1.4.34 IW-B — bfcache-friendly directive rides on the slim
|
||||
// slice too.
|
||||
@@ -369,17 +363,17 @@ describe("GET /api/analytics", () => {
|
||||
// both fields rather than 403-ing the whole core-metrics payload.
|
||||
function seedGlucoseRows() {
|
||||
const measuredAt = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation(
|
||||
(async (args: { where?: { type?: string } }) => {
|
||||
if (args?.where?.type === "BLOOD_GLUCOSE") {
|
||||
return [
|
||||
{ value: 95, measuredAt, glucoseContext: "FASTING" },
|
||||
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never,
|
||||
);
|
||||
vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
|
||||
where?: { type?: string };
|
||||
}) => {
|
||||
if (args?.where?.type === "BLOOD_GLUCOSE") {
|
||||
return [
|
||||
{ value: 95, measuredAt, glucoseContext: "FASTING" },
|
||||
{ value: 140, measuredAt, glucoseContext: "POSTPRANDIAL" },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
}
|
||||
|
||||
it("serves glucose clinical + per-context when glucose enabled", async () => {
|
||||
|
||||
@@ -72,7 +72,9 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// instantly while one background recompute refreshes them, instead
|
||||
// of paying the 2-SQL-pass rebuild inline.
|
||||
const slim = await cachedSwr(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof computeSummariesSlice>>>,
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof computeSummariesSlice>>
|
||||
>,
|
||||
`${user.id}|summaries`,
|
||||
() => computeSummariesSlice(user.id),
|
||||
annotate,
|
||||
@@ -95,7 +97,9 @@ export const GET = apiHandler(async (request?: Request) => {
|
||||
// hero score; after a measurement sync the next mount serves the
|
||||
// prior body instantly while one background recompute refreshes it.
|
||||
const cachedBody = await cachedSwr(
|
||||
caches.analytics as ServerCache<Awaited<ReturnType<typeof buildAnalyticsResponse>>>,
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildAnalyticsResponse>>
|
||||
>,
|
||||
`${user.id}|default`,
|
||||
() => buildAnalyticsResponse(user),
|
||||
annotate,
|
||||
@@ -596,7 +600,6 @@ async function computeSleepStageBreakdown(
|
||||
// used by the weight-weekday + day-key helpers inside the old inline
|
||||
// correlation builder.
|
||||
|
||||
|
||||
// v1.4.37 W2 — `mapMeasurementSourceToLabel`, `uniqueComponentSources`,
|
||||
// and the inline `computeUserHealthScore` body relocated to
|
||||
// `src/lib/analytics/health-score-fast-path.ts` so the probe-gated
|
||||
|
||||
@@ -52,8 +52,7 @@ vi.mock("@/lib/auth/hmac", () => ({
|
||||
// privacy contract is "the typed identifier never serialises into
|
||||
// the row's body", so the mock must not embed the raw input.
|
||||
hashToken: vi.fn(
|
||||
(raw: string) =>
|
||||
"aa".repeat(32) + (raw.length % 8).toString(16),
|
||||
(raw: string) => "aa".repeat(32) + (raw.length % 8).toString(16),
|
||||
),
|
||||
}));
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ vi.mock("@/lib/db", () => ({
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: <T,>(v: T) => v,
|
||||
toJson: <T>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -87,12 +87,14 @@ function mkPatch(body: unknown): Request {
|
||||
* (which writes `modulePreferencesJson`) is reflected by the post-update
|
||||
* `resolveModuleMap` re-read — mirroring real DB round-trip semantics.
|
||||
*/
|
||||
function primeUser(over: {
|
||||
gender?: string | null;
|
||||
disableCoach?: boolean;
|
||||
modulePreferencesJson?: unknown;
|
||||
cycleTrackingEnabled?: boolean | null;
|
||||
} = {}) {
|
||||
function primeUser(
|
||||
over: {
|
||||
gender?: string | null;
|
||||
disableCoach?: boolean;
|
||||
modulePreferencesJson?: unknown;
|
||||
cycleTrackingEnabled?: boolean | null;
|
||||
} = {},
|
||||
) {
|
||||
const row: {
|
||||
gender: string | null;
|
||||
disableCoach: boolean;
|
||||
@@ -102,9 +104,8 @@ function primeUser(over: {
|
||||
disableCoach: over.disableCoach ?? false,
|
||||
modulePreferencesJson: over.modulePreferencesJson ?? null,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation(
|
||||
(() => Promise.resolve({ ...row })) as never,
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockImplementation((() =>
|
||||
Promise.resolve({ ...row })) as never);
|
||||
vi.mocked(prisma.user.update).mockImplementation(((args: {
|
||||
data?: { modulePreferencesJson?: unknown };
|
||||
}) => {
|
||||
@@ -213,7 +214,9 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
// user previously disabled glucose; now disabling sleep too.
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ sleep: false }));
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ sleep: false }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
@@ -225,7 +228,9 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
primeUser({ modulePreferencesJson: { glucose: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ glucose: true }));
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ glucose: true }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
@@ -299,7 +304,9 @@ describe("PATCH /api/auth/me/modules", () => {
|
||||
// junk in the stored row must not leak into the persisted merge.
|
||||
primeUser({ modulePreferencesJson: { mood: "yes", sleep: false } });
|
||||
|
||||
await (PATCH as (r: Request) => Promise<Response>)(mkPatch({ labs: false }));
|
||||
await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ labs: false }),
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
|
||||
@@ -131,9 +131,7 @@ describe("GET /api/auth/me/research-mode", () => {
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -165,9 +163,7 @@ describe("POST /api/auth/me/research-mode", () => {
|
||||
data: { enabled: boolean; acknowledgedVersion: string };
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
expect(env.data.acknowledgedVersion).toBe(RESEARCH_MODE_DISCLAIMER_VERSION);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -171,7 +171,8 @@ export const POST = apiHandler(async (req: Request) => {
|
||||
previous: previous ?? null,
|
||||
next: {
|
||||
...next,
|
||||
researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
|
||||
researchModeAcknowledgedAt:
|
||||
next.researchModeAcknowledgedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -131,7 +131,10 @@ describe("PATCH /api/auth/me/unit-preference", () => {
|
||||
"user.unit-preference.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({ previous: "metric", next: "imperial" }),
|
||||
details: expect.objectContaining({
|
||||
previous: "metric",
|
||||
next: "imperial",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { createAuthenticationOptions } from "@/lib/auth/passkey";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
checkAuthSurfaceRateLimit,
|
||||
rateLimitHeaders,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
@@ -53,10 +53,7 @@ vi.mock("next/headers", () => ({
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { verifyAuthentication } from "@/lib/auth/passkey";
|
||||
import {
|
||||
checkRateLimit,
|
||||
checkAuthSurfaceRateLimit,
|
||||
} from "@/lib/rate-limit";
|
||||
import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const FAKE_USER = {
|
||||
id: "user-1",
|
||||
|
||||
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
verifyPassword: vi.fn().mockResolvedValue(true),
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed"),
|
||||
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
checkPasswordStrength: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -81,9 +83,7 @@ beforeEach(() => {
|
||||
describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces TWO simultaneous validation errors", async () => {
|
||||
// Empty currentPassword + tooShort newPassword.
|
||||
const res = await POST(
|
||||
postReq({ currentPassword: "", newPassword: "x" }),
|
||||
);
|
||||
const res = await POST(postReq({ currentPassword: "", newPassword: "x" }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
data: null;
|
||||
@@ -102,9 +102,7 @@ describe("POST /api/auth/password — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
|
||||
it("surfaces THREE simultaneous validation errors", async () => {
|
||||
// Both empty + extra invalid type.
|
||||
const res = await POST(
|
||||
postReq({ currentPassword: "", newPassword: 123 }),
|
||||
);
|
||||
const res = await POST(postReq({ currentPassword: "", newPassword: 123 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
|
||||
@@ -23,7 +23,9 @@ vi.mock("@/lib/auth/session", () => ({
|
||||
|
||||
vi.mock("@/lib/auth/password", () => ({
|
||||
hashPassword: vi.fn().mockResolvedValue("hashed"),
|
||||
checkPasswordStrength: vi.fn().mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
checkPasswordStrength: vi
|
||||
.fn()
|
||||
.mockReturnValue({ isAcceptable: true, feedback: [] }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
@@ -144,7 +144,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
} else {
|
||||
annotate({
|
||||
action: { name: "auth.register.invite_rejected" },
|
||||
meta: { reason: consumed.reason, registration_open: registrationEnabled },
|
||||
meta: {
|
||||
reason: consumed.reason,
|
||||
registration_open: registrationEnabled,
|
||||
},
|
||||
});
|
||||
if (!registrationEnabled) {
|
||||
return apiError("Invalid or expired invite", 403);
|
||||
|
||||
@@ -89,9 +89,7 @@ describe("POST /api/bugreport — 422 multi-issue (v1.4.43 W6)", () => {
|
||||
it("surfaces multiple simultaneous validation errors (≥ 2)", async () => {
|
||||
// Schema has only two knobs (description, screenshot). Pin contract
|
||||
// on ≥ 2.
|
||||
const res = await POST(
|
||||
postReq({ description: 123, screenshot: 999 }),
|
||||
);
|
||||
const res = await POST(postReq({ description: 123, screenshot: 999 }));
|
||||
expect(res.status).toBe(422);
|
||||
const body = (await res.json()) as {
|
||||
details: { issues: Array<unknown> };
|
||||
|
||||
@@ -46,10 +46,7 @@ function reject() {
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (
|
||||
request: NextRequest,
|
||||
ctx: { params: Promise<{ token: string }> },
|
||||
) => {
|
||||
async (request: NextRequest, ctx: { params: Promise<{ token: string }> }) => {
|
||||
const { token } = await ctx.params;
|
||||
annotate({ action: { name: "share-link.unlock" } });
|
||||
|
||||
@@ -91,14 +88,18 @@ export const POST = apiHandler(
|
||||
|
||||
// Correct passphrase — mint the short-lived, token-scoped unlock cookie.
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(unlockCookieName(gate.tokenHash), mintUnlockValue(gate.tokenHash), {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "strict",
|
||||
// Scope to THIS token's view path only — the cookie unlocks nothing else.
|
||||
path: `/c/${token}`,
|
||||
maxAge: UNLOCK_TTL_SECONDS,
|
||||
});
|
||||
cookieStore.set(
|
||||
unlockCookieName(gate.tokenHash),
|
||||
mintUnlockValue(gate.tokenHash),
|
||||
{
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "strict",
|
||||
// Scope to THIS token's view path only — the cookie unlocks nothing else.
|
||||
path: `/c/${token}`,
|
||||
maxAge: UNLOCK_TTL_SECONDS,
|
||||
},
|
||||
);
|
||||
|
||||
annotate({ meta: { unlock: "granted" } });
|
||||
return apiSuccess({ unlocked: true });
|
||||
|
||||
@@ -13,11 +13,7 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { apiSuccess, returnAllZodIssues, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
getPendingQuestionsForUser,
|
||||
|
||||
@@ -38,10 +38,7 @@ import {
|
||||
DEFAULT_REMINDER_SUGGESTION_PREFS,
|
||||
} from "@/lib/validations/coach-prefs";
|
||||
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
|
||||
import {
|
||||
CADENCE_CATALOG,
|
||||
isCadenceId,
|
||||
} from "@/lib/ai/coach/suggest-reminder";
|
||||
import { CADENCE_CATALOG, isCadenceId } from "@/lib/ai/coach/suggest-reminder";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
|
||||
@@ -36,7 +36,6 @@ vi.mock("@/lib/auth/audit", () => ({
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
@@ -440,9 +440,7 @@ describe("append-only invariant", () => {
|
||||
it("GET surfaces multi-issue validation errors (≥ 1)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=junk",
|
||||
),
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=junk"),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
@@ -467,10 +465,9 @@ describe("append-only invariant", () => {
|
||||
it("DELETE surfaces multi-issue validation errors (≥ 1)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=junk",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
new NextRequest("http://localhost/api/consent/ai/latest?kind=junk", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
|
||||
@@ -16,11 +16,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
} from "@/lib/api-response";
|
||||
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkConsentRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -76,7 +72,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// Map each enum value to either the serialised row or `null` so the
|
||||
// client always sees the full keyspace and never has to guess
|
||||
// whether a missing key means "not granted" or "schema gap".
|
||||
const result: Record<ConsentKind, ReturnType<typeof serialiseReceipt> | null> = {
|
||||
const result: Record<
|
||||
ConsentKind,
|
||||
ReturnType<typeof serialiseReceipt> | null
|
||||
> = {
|
||||
ai_full: byKind.ai_full ? serialiseReceipt(byKind.ai_full) : null,
|
||||
ai_insights_only: byKind.ai_insights_only
|
||||
? serialiseReceipt(byKind.ai_insights_only)
|
||||
|
||||
@@ -53,7 +53,12 @@ import { apiError } from "@/lib/api-response";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "t", role: "USER" as const, gender: "FEMALE" },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "t",
|
||||
role: "USER" as const,
|
||||
gender: "FEMALE",
|
||||
},
|
||||
};
|
||||
|
||||
function jsonReq(url: string, method: string, body: unknown) {
|
||||
@@ -92,7 +97,10 @@ describe("GET /api/cycle/symptoms/custom", () => {
|
||||
describe("POST /api/cycle/symptoms/custom", () => {
|
||||
it("creates a custom symptom, stores the label encrypted, returns it", async () => {
|
||||
db.cycleSymptom.count.mockResolvedValue(2);
|
||||
db.cycleSymptom.create.mockResolvedValue({ key: "custom:abc", icon: "Brain" });
|
||||
db.cycleSymptom.create.mockResolvedValue({
|
||||
key: "custom:abc",
|
||||
icon: "Brain",
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
|
||||
label: "Dizziness",
|
||||
@@ -122,7 +130,9 @@ describe("POST /api/cycle/symptoms/custom", () => {
|
||||
it("rejects over the per-user cap with 422", async () => {
|
||||
db.cycleSymptom.count.mockResolvedValue(50);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
|
||||
label: "X",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(db.cycleSymptom.create).not.toHaveBeenCalled();
|
||||
@@ -134,7 +144,9 @@ describe("POST /api/cycle/symptoms/custom", () => {
|
||||
response: apiError("Cycle tracking is not enabled", 403),
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", { label: "X" }),
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom", "POST", {
|
||||
label: "X",
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(403);
|
||||
expect(db.cycleSymptom.count).not.toHaveBeenCalled();
|
||||
@@ -170,9 +182,13 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
|
||||
db.cycleSymptom.findFirst.mockResolvedValue({ id: "id1", isActive: false });
|
||||
db.cycleSymptom.count.mockResolvedValue(50);
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
|
||||
isActive: true,
|
||||
}),
|
||||
jsonReq(
|
||||
"http://localhost/api/cycle/symptoms/custom/custom:abc",
|
||||
"PATCH",
|
||||
{
|
||||
isActive: true,
|
||||
},
|
||||
),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
@@ -188,9 +204,13 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
|
||||
labelEncrypted: "enc:Renamed",
|
||||
});
|
||||
const res = await PATCH(
|
||||
jsonReq("http://localhost/api/cycle/symptoms/custom/custom:abc", "PATCH", {
|
||||
label: "Renamed",
|
||||
}),
|
||||
jsonReq(
|
||||
"http://localhost/api/cycle/symptoms/custom/custom:abc",
|
||||
"PATCH",
|
||||
{
|
||||
label: "Renamed",
|
||||
},
|
||||
),
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -224,6 +244,8 @@ describe("PATCH/DELETE /api/cycle/symptoms/custom/:key", () => {
|
||||
params("custom:abc"),
|
||||
);
|
||||
expect(purge.status).toBe(200);
|
||||
expect(db.cycleSymptom.delete).toHaveBeenCalledWith({ where: { id: "id1" } });
|
||||
expect(db.cycleSymptom.delete).toHaveBeenCalledWith({
|
||||
where: { id: "id1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -107,7 +107,9 @@ export const GET = apiHandler(async () => {
|
||||
// Only surface timings on the cache-miss path (the hit path skips the
|
||||
// whole builder and leaves `timings` empty).
|
||||
if (Object.keys(timings).length > 0) {
|
||||
annotate({ meta: { ...timings, snapshot_extras_present: body.extras !== null } });
|
||||
annotate({
|
||||
meta: { ...timings, snapshot_extras_present: body.extras !== null },
|
||||
});
|
||||
}
|
||||
|
||||
const response = apiSuccess(body);
|
||||
|
||||
@@ -325,7 +325,10 @@ describe("GET /api/dashboard/summary", () => {
|
||||
};
|
||||
};
|
||||
const bp = body.data.metrics.find((m) => m.id === "bp");
|
||||
expect(bp, "bp tile must be emitted for stale-but-valid history").toBeDefined();
|
||||
expect(
|
||||
bp,
|
||||
"bp tile must be emitted for stale-but-valid history",
|
||||
).toBeDefined();
|
||||
expect(bp?.latestValue).toBe(132);
|
||||
expect(bp?.secondaryValue).toBe(84);
|
||||
expect(bp?.allTimeCount).toBe(28);
|
||||
@@ -421,13 +424,55 @@ describe("GET /api/dashboard/summary", () => {
|
||||
{ type: "PULSE", value: 71, measured_at: days(30) },
|
||||
] as never)
|
||||
.mockResolvedValueOnce([
|
||||
{ type: "PULSE", bucket_start: days(36), mean: 68, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(35), mean: 70, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(34), mean: 69, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(33), mean: 71, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(32), mean: 73, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(31), mean: 72, count: 3, sum_value: null },
|
||||
{ type: "PULSE", bucket_start: days(30), mean: 71, count: 3, sum_value: null },
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(36),
|
||||
mean: 68,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(35),
|
||||
mean: 70,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(34),
|
||||
mean: 69,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(33),
|
||||
mean: 71,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(32),
|
||||
mean: 73,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(31),
|
||||
mean: 72,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
{
|
||||
type: "PULSE",
|
||||
bucket_start: days(30),
|
||||
mean: 71,
|
||||
count: 3,
|
||||
sum_value: null,
|
||||
},
|
||||
] as never)
|
||||
.mockResolvedValueOnce([] as never);
|
||||
|
||||
@@ -498,10 +543,26 @@ describe("GET /api/dashboard/summary", () => {
|
||||
] as never);
|
||||
// Raw per-stage rows for the night (the sleep findMany read).
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([
|
||||
{ value: 480, measuredAt: new Date("2026-06-03T23:00:00.000Z"), sleepStage: "IN_BED" },
|
||||
{ value: 240, measuredAt: new Date("2026-06-04T00:00:00.000Z"), sleepStage: "CORE" },
|
||||
{ value: 90, measuredAt: new Date("2026-06-04T02:00:00.000Z"), sleepStage: "DEEP" },
|
||||
{ value: 80, measuredAt: new Date("2026-06-04T04:00:00.000Z"), sleepStage: "REM" },
|
||||
{
|
||||
value: 480,
|
||||
measuredAt: new Date("2026-06-03T23:00:00.000Z"),
|
||||
sleepStage: "IN_BED",
|
||||
},
|
||||
{
|
||||
value: 240,
|
||||
measuredAt: new Date("2026-06-04T00:00:00.000Z"),
|
||||
sleepStage: "CORE",
|
||||
},
|
||||
{
|
||||
value: 90,
|
||||
measuredAt: new Date("2026-06-04T02:00:00.000Z"),
|
||||
sleepStage: "DEEP",
|
||||
},
|
||||
{
|
||||
value: 80,
|
||||
measuredAt: new Date("2026-06-04T04:00:00.000Z"),
|
||||
sleepStage: "REM",
|
||||
},
|
||||
{ value: 20, measuredAt: wake, sleepStage: "AWAKE" },
|
||||
] as never);
|
||||
const res = await callGet(makeReq());
|
||||
|
||||
@@ -334,9 +334,9 @@ export const GET = apiHandler(async () => {
|
||||
// existing `invalidateUserMeasurements` + the v1.4.38-extended
|
||||
// `invalidateUserMedications` hooks.
|
||||
const body = await cached(
|
||||
caches.analytics as ServerCache<Awaited<
|
||||
ReturnType<typeof buildDashboardSummary>
|
||||
>>,
|
||||
caches.analytics as ServerCache<
|
||||
Awaited<ReturnType<typeof buildDashboardSummary>>
|
||||
>,
|
||||
`${user.id}|dashboard-summary`,
|
||||
() => buildDashboardSummary(user.id, userTz, buildContext(user)),
|
||||
annotate,
|
||||
@@ -350,9 +350,11 @@ interface SummaryBuilderContext {
|
||||
locale: Locale;
|
||||
}
|
||||
|
||||
function buildContext(
|
||||
user: { displayName: string | null; username: string; locale: string | null },
|
||||
): SummaryBuilderContext {
|
||||
function buildContext(user: {
|
||||
displayName: string | null;
|
||||
username: string;
|
||||
locale: string | null;
|
||||
}): SummaryBuilderContext {
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
// v1.5.x — accept every shipped locale (de / en / es / fr / it / pl)
|
||||
// so the greeting + streak label resolve against the user's
|
||||
@@ -395,7 +397,10 @@ async function buildDashboardSummary(
|
||||
// perf-verify can attribute a regression to a specific sub-query
|
||||
// without re-instrumenting the route.
|
||||
const timings: Record<string, number> = {};
|
||||
const time = async <T>(label: string, builder: () => Promise<T>): Promise<T> => {
|
||||
const time = async <T>(
|
||||
label: string,
|
||||
builder: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const t0 = Date.now();
|
||||
const result = await builder();
|
||||
timings[`dashboard.sub_${label}_ms`] = Date.now() - t0;
|
||||
@@ -424,8 +429,10 @@ async function buildDashboardSummary(
|
||||
measurementStreakDays,
|
||||
sleepStageRows,
|
||||
] = await Promise.all([
|
||||
time("latestEver", () =>
|
||||
prisma.$queryRaw<LatestEverRow[]>`
|
||||
time(
|
||||
"latestEver",
|
||||
() =>
|
||||
prisma.$queryRaw<LatestEverRow[]>`
|
||||
SELECT DISTINCT ON (m."type")
|
||||
m."type" AS type,
|
||||
m."value"::double precision AS value,
|
||||
@@ -436,8 +443,10 @@ async function buildDashboardSummary(
|
||||
ORDER BY m."type", m."measured_at" DESC
|
||||
`,
|
||||
),
|
||||
time("sparkline", () =>
|
||||
prisma.$queryRaw<SparklineRow[]>`
|
||||
time(
|
||||
"sparkline",
|
||||
() =>
|
||||
prisma.$queryRaw<SparklineRow[]>`
|
||||
SELECT type, bucket_start, mean, count, sum_value
|
||||
FROM (
|
||||
SELECT
|
||||
@@ -497,8 +506,10 @@ async function buildDashboardSummary(
|
||||
// conversion is folded server-side so the day boundary matches
|
||||
// `userDayKey`'s output (it formats the timestamp in the user's
|
||||
// tz before slicing YYYY-MM-DD).
|
||||
time("streakDays", () =>
|
||||
prisma.$queryRaw<ActivityDayRow[]>`
|
||||
time(
|
||||
"streakDays",
|
||||
() =>
|
||||
prisma.$queryRaw<ActivityDayRow[]>`
|
||||
SELECT DISTINCT
|
||||
to_char(
|
||||
(m."measured_at" AT TIME ZONE ${userTz}),
|
||||
|
||||
@@ -37,8 +37,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return {
|
||||
...actual,
|
||||
annotate: vi.fn(),
|
||||
@@ -188,11 +187,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
expect(annotated, "validation-failed annotate call").toBeTruthy();
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
|
||||
@@ -206,7 +207,7 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
|
||||
256,
|
||||
);
|
||||
expect(meta.received_shape_excerpt as string).toContain("\"version\":2");
|
||||
expect(meta.received_shape_excerpt as string).toContain('"version":2');
|
||||
|
||||
// zod_issues is the same sanitised array surfaced under details.issues.
|
||||
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
|
||||
@@ -230,11 +231,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
const res = await callPut(makeReq({ version: 99, widgets }));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
expect((meta.received_shape_excerpt as string).length).toBe(256);
|
||||
});
|
||||
@@ -268,11 +271,13 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
|
||||
const res = await callPut(makeReq(payload));
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const annotated = vi.mocked(annotate).mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const annotated = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(call) =>
|
||||
(call[0] as { action?: { name?: string } })?.action?.name ===
|
||||
"dashboard.widgets.validation-failed",
|
||||
);
|
||||
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
const excerpt = meta.received_shape_excerpt as string;
|
||||
|
||||
@@ -337,7 +342,8 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
|
||||
|
||||
// The persisted blob never carries the unknown id.
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
const updateArg = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as unknown as {
|
||||
const updateArg = vi.mocked(prisma.user.update).mock
|
||||
.calls[0]?.[0] as unknown as {
|
||||
data: { dashboardWidgetsJson: { widgets: Array<{ id: string }> } };
|
||||
};
|
||||
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
|
||||
@@ -372,11 +378,13 @@ describe("PUT /api/dashboard/widgets — accept-and-ignore unknown ids (v1.7.0 #
|
||||
// annotation fires regardless, before the Zod parse.
|
||||
expect(res.status).toBe(422);
|
||||
|
||||
const dropAnnotate = vi.mocked(annotate).mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
const dropAnnotate = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
expect(dropAnnotate, "unknown-id-dropped annotate call").toBeTruthy();
|
||||
const meta = (dropAnnotate![0] as { meta?: Record<string, unknown> }).meta!;
|
||||
expect(meta.dropped_count).toBe(200);
|
||||
@@ -542,11 +550,13 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// No id was dropped — the unknown-id annotation must NOT fire.
|
||||
const dropAnnotate = vi.mocked(annotate).mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
const dropAnnotate = vi
|
||||
.mocked(annotate)
|
||||
.mock.calls.find(
|
||||
(c) =>
|
||||
(c[0] as { action?: { name?: string } }).action?.name ===
|
||||
"dashboard.widgets.unknown-id-dropped",
|
||||
);
|
||||
expect(dropAnnotate).toBeUndefined();
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
@@ -557,7 +567,9 @@ describe("dashboard widgets — 27-id catalogue round-trip (v1.7.0 W1)", () => {
|
||||
const persistedIds = updateArg.data.dashboardWidgetsJson.widgets.map(
|
||||
(w) => w.id,
|
||||
);
|
||||
expect(persistedIds.sort()).toEqual([...DASHBOARD_WIDGET_CATALOGUE_IDS].sort());
|
||||
expect(persistedIds.sort()).toEqual(
|
||||
[...DASHBOARD_WIDGET_CATALOGUE_IDS].sort(),
|
||||
);
|
||||
// The response body echoes the full persisted layout.
|
||||
const body = (await res.json()) as {
|
||||
data: { widgets: Array<{ id: string }> };
|
||||
|
||||
@@ -271,7 +271,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
let mergedChartOverlayPrefs: ChartOverlayPrefsMap | undefined = parsed.data
|
||||
.chartOverlayPrefs as ChartOverlayPrefsMap | undefined;
|
||||
let mergedHeroVisible: boolean | undefined = parsed.data.heroVisible;
|
||||
if (mergedChartOverlayPrefs === undefined || mergedHeroVisible === undefined) {
|
||||
if (
|
||||
mergedChartOverlayPrefs === undefined ||
|
||||
mergedHeroVisible === undefined
|
||||
) {
|
||||
const existing = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { dashboardWidgetsJson: true },
|
||||
|
||||
@@ -104,7 +104,9 @@ beforeEach(() => {
|
||||
vi.mocked(prisma.medicationIntakeEvent.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
|
||||
vi.mocked(prisma.moodEntry.count).mockResolvedValue(0);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({ heightCm: null } as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
heightCm: null,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
@@ -140,9 +142,7 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
|
||||
|
||||
it("/api/gamification/achievements scopes the measurement read to deletedAt: null", async () => {
|
||||
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
|
||||
const { GET } = await import(
|
||||
"../../gamification/achievements/route"
|
||||
);
|
||||
const { GET } = await import("../../gamification/achievements/route");
|
||||
await GET(mkReq("http://localhost/api/gamification/achievements"));
|
||||
const calls = vi.mocked(prisma.measurement.findMany).mock.calls;
|
||||
// The achievement aggregator reads recent measurements via
|
||||
|
||||
@@ -104,9 +104,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const locale: Locale =
|
||||
selection.locale ??
|
||||
cookieLocale ??
|
||||
parseLocaleFromAcceptLanguage(
|
||||
request.headers.get("accept-language") ?? "",
|
||||
);
|
||||
parseLocaleFromAcceptLanguage(request.headers.get("accept-language") ?? "");
|
||||
|
||||
// BfArM ATC: an explicit selection flag wins; otherwise derive it from a
|
||||
// German-region locale. The WHO ATC coding is unaffected either way.
|
||||
@@ -207,12 +205,9 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
// format === "package": one zip holding the PDF + FHIR Bundle + a README.
|
||||
const bundle = buildFhirDocumentBundle(
|
||||
data,
|
||||
{ insuranceNumber },
|
||||
undefined,
|
||||
{ germanAtc },
|
||||
);
|
||||
const bundle = buildFhirDocumentBundle(data, { insuranceNumber }, undefined, {
|
||||
germanAtc,
|
||||
});
|
||||
const readme = t("doctorReport.packageReadme");
|
||||
const zipped = zipSync(
|
||||
{
|
||||
|
||||
@@ -46,8 +46,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? "raw"
|
||||
: "night";
|
||||
|
||||
const [measurements, userTz, sourcePriorityJson, profile] =
|
||||
await Promise.all([
|
||||
const [measurements, userTz, sourcePriorityJson, profile] = await Promise.all(
|
||||
[
|
||||
prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
@@ -60,7 +60,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
where: { id: user.id },
|
||||
select: { glucoseUnit: true },
|
||||
}),
|
||||
]);
|
||||
],
|
||||
);
|
||||
const glucoseUnit = resolveGlucoseUnit(profile?.glucoseUnit ?? null);
|
||||
|
||||
const csv = toCSV(
|
||||
|
||||
Loaded 100 of 1000 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user