chore(release): v1.4.40 — comprehensive Critical+High architecture closure plus iOS PB30 enablement

Eleven touch-disjoint waves landed on develop plus a six-axis QA
reconcile round before tagging. See CHANGELOG.md for the user-facing
prose; this commit message documents the wave + report inventory for
release-management traceability.

Waves shipped (each with a phase report under .planning/):

- phase-W-POOL-v1440-report.md — p-limit(4) wrapper around the
  analytics 15-way fan-out + pg.Pool max raised 10 -> 20. Closes the
  v1.4.39 empirical-trace finding #1 (Prisma pool starvation).
- phase-W-DELETED-v1440-report.md — 11 reader-tier helpers + 3
  integration contracts close audit Critical Finding #3 (soft-delete
  invisibility) across dashboard, analytics, summaries, rollup tiers,
  and the Coach snapshot path.
- phase-W-INSIGHTS-v1440-report.md — mood-rollup swap on the three
  /api/insights/{features,targets,cards} routes plus the
  compliance-rollup hook gap closure for bulk-projection mints; the
  post-QA reconcile patch closes the six remaining measurement.findMany
  sites in the cluster that still leaked tombstoned rows.
- phase-W-INFRA-v1440-report.md — rollup helper umbrella consolidation
  under src/lib/rollups/* with zero orphan re-exports; lint, typecheck,
  knip, and 93/93 rollup tests green post-restructure.
- phase-W-RSC-v1440-report.md — per-tile Suspense boundaries plus the
  queryKey factory-bypass walker test (extended to comparison/ in the
  reconcile pass).
- phase-W-PRIVACY-v1440-report.md — bilingual /privacy page with 17
  paired-section tests pinning the 9 SB-3 disclosure requirements.
- phase-W-AASA-v1440-report.md — /.well-known/apple-app-site-association
  endpoint pinned to S8WDX4W5KX.dev.healthlog.app with app-ID parity
  assertions guarding against split rotation.
- phase-W-APNS-NOTIFY-v1440-report.md — MEDICATION_REMINDER
  time-sensitive APNs payload (priority 10 + interruption-level)
  scoped via a parameterised test that pins all 6 other event-types
  do not bypass Focus. AP-2 .p8 install gates real delivery.
- phase-W-CONSENT-v1440-report.md — AI consent receipts CRUD with
  append-only invariant pinned in test; revoke-then-regrant leaves
  both rows; idempotent DELETE returns 200 + receipt: null.
- phase-W-WMY-WIRE-v1440-report.md — three previously-dead WMY readers
  (week/month/year) wired through summaries-slice and
  health-score-fast-path with a linear-composability parity test
  pinning the cross-consumer routing contract.
- phase-W-GHOSTS-v1440-report.md — ~1 177 lines of dead code removed
  across 9 atomic commits; startOfUtcDay + wallClockInTz consolidation
  eliminates a sibling-drift risk; knip whitelist trimmed to three
  files pending follow-up.

QA reconcile patches applied per the v1.4.40 QA round (six reports
under .planning/round-v1440-QA-*-findings.md):

- senior C1 — dashboard-suspense-boundaries regex aligned with the
  post-W-INFRA Thread 2 userTimezone-local lift.
- senior H1 — soft-delete filter added to the six remaining
  insights measurement.findMany sites.
- specialist H1 — Promise.allSettled around bulk-projection
  compliance recompute on both /api/medications/intake and
  /api/dashboard/summary.
- ux H1 — compare-toggle migrated to queryKeys.dashboardWidgets
  factory; walker test extended to src/components/comparison/.
- security M1 — consent artefact 64 KB cap enforced via
  Buffer.byteLength (UTF-8 bytes, not UTF-16 code units).

Quality gates green at tag: pnpm typecheck, pnpm lint, pnpm knip
--include files,dependencies,binaries,unlisted, pnpm test --run
(4726 passing / 1 skipped). Tests 4525 -> 4726 unit + 1 skipped over
the v1.4.39 -> v1.4.40 span.

User-facing prose: see CHANGELOG.md.
This commit is contained in:
Marc-André Bombeck committed 2026-05-21 12:48:02 +02:00
1 parent f9996552ed
commit 1114cf7e63
146 files changed
+8207 -2279

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
name: Knip
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
knip:
name: Dead-code gate
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm db:generate
# v1.4.40 W-INFRA — fail the build when knip surfaces a new dead
# module, an unused production dependency, an unused devDependency,
# or an unlisted binary. Unused exports + types are intentionally
# left informational while the existing surface is cleaned wave by
# wave (487 exports / 52 types on the v1.4.40 baseline). The
# baseline carve-outs live in `knip.json` under `ignore` +
# `ignoreDependencies` + `ignoreBinaries` with the rationale
# captured in `.planning/phase-W-INFRA-v1440-report.md`.
- run: pnpm knip --reporter compact --include files,dependencies,binaries,unlisted
+73
View File
@@ -0,0 +1,73 @@
# Phase W-AASA — v1.4.40 — Apple App Site Association (SB-4)
Agent: W-AASA. Single-axis scope: serve the AASA payload required by
iOS Universal Links and Web Credentials so iOS PB30 (App-Store
submission) is unblocked.
## What changed
- `src/app/.well-known/apple-app-site-association/route.ts` — already
existed as a route handler (the preferred Next.js App-Router pattern
for an extension-less `/.well-known/*` asset). The previous body
carried an empty `applinks.details` array, which let passkey
Web-Credentials work but left Universal Links unwired. The handler
now populates `applinks.details` with the SB-4 App ID
(`S8WDX4W5KX.dev.healthlog.app`) and the `["*"]` path matcher, so
every HealthLog URL becomes a Universal Link candidate on devices
that have the app installed. Both the `applinks` and
`webcredentials.apps` entries share the same App ID prefix via a
local `AASA_APP_ID` constant — splitting them silently breaks
passkey login on iOS.
- `src/__tests__/api/well-known.test.ts` — new five-assertion unit test
that pins:
- HTTP 200 on a bare `GET`.
- `Content-Type: application/json` exactly (no `charset` parameter
— Apple's swcd / aasa-validator refuse to mirror anything
annotated with `charset=…`, and the symptom is silent: Universal
Links just stop firing).
- A `public, max-age=N` `Cache-Control` that lets Apple's CDN
mirror the file.
- The exact JSON shape from the SB-4 spec.
- `applinks.details[0].appID === webcredentials.apps[0]` — guard
against the maintainer accidentally rotating one App ID without
the other.
## Implementation choice — route handler over `public/`
`grep` for `.well-known` surfaced an existing route handler at
`src/app/.well-known/apple-app-site-association/route.ts` (added in
v1.4.33) plus an existing proxy guard in
`src/__tests__/proxy-well-known-public.test.ts` that admits the path
through `proxy.ts` without a session cookie. The route-handler
convention is already the house style and is the right call for an
extension-less file (Next.js does not serve extension-less files from
`public/` cleanly, and a route handler lets us set the exact
`Content-Type` and `Cache-Control` headers Apple requires). No
duplicate file was added under `public/`.
## Quality gates
- `pnpm vitest run src/__tests__/api/well-known.test.ts` — 5/5 pass.
- `pnpm vitest run src/__tests__/proxy-well-known-public.test.ts` —
4/4 pass (existing AASA proxy guard still green).
- `pnpm tsc --noEmit` — clean.
- `pnpm exec eslint <changed files>` — clean.
## Commits on `develop`
- `feat(aasa): serve apple-app-site-association at the well-known path`
- `test(aasa): pin payload shape and content-type`
## Out of scope (deliberate)
- No change to `src/proxy.ts` — the existing
`WELL_KNOWN_PUBLIC_PATHS` allowlist already exposes the path
unauthenticated, and the matcher in `proxy.ts:280–284` excludes
static-file extensions but lets extension-less paths through.
- No change to `CHANGELOG.md` or `package.json` — SB-4 is one slice of
the v1.4.40 release and the release-master will bundle the version
bump.
- No iOS-side entitlement edits — the iOS team's PB30 phase owns the
`Associated Domains` entitlement (`applinks:healthlog.bombeck.io`
and `webcredentials:healthlog.bombeck.io`).
@@ -0,0 +1,47 @@
# Phase W-APNS-NOTIFY — v1.4.40 report
## Scope
- **SB-5**: Conditional `interruption-level: "time-sensitive"` + `apns-priority: 10` on the APNs payload, restricted to `MEDICATION_REMINDER` only.
- **SB-6**: `GET /api/notifications/status` extended with a per-event-type `events` map (`{ [category]: { lastDeliveredAt: ISO8601 | null } }`) so the iOS NotificationsScreen can render "last delivered Xh ago" per category.
## Changes
### SB-5 — apns.ts
- `src/lib/notifications/senders/apns.ts`:
- `ApnsPayload` gains `interruptionLevel?: "passive" | "active" | "time-sensitive" | "critical"` and `priority?: 5 | 10`.
- `sendApnsPush()` writes both fields through to node-apn — `interruptionLevel` via a typed cast (mirrors the `category` setter pattern, node-apn 8.1 d.ts omits the public setter), `priority` directly (d.ts already exposes it).
- `sendViaApns()` sets `isTimeSensitive = payload.eventType === "MEDICATION_REMINDER"` and conditionally spreads `{ interruptionLevel: "time-sensitive", priority: 10 }` into the payload. Every other event-type (MOOD_REMINDER, MEASUREMENT_ANOMALY, COMPLIANCE_LOW, WITHINGS_SYNC_FAILED, SYSTEM_ALERT, PERSONAL_RECORD) stays on the iOS default `active` level so Focus modes — including Sleep — keep silencing them.
### SB-6 — status route
- `src/app/api/notifications/status/route.ts`:
- New `loadEventStatuses(userId)` helper. Initialises the response map with every known `EVENT_TYPES` entry → `{ lastDeliveredAt: null }`, then populates `MOOD_REMINDER` from `MoodReminderDispatch.dispatchedAt` (the only per-event ledger that exists today).
- GET handler now runs `prisma.notificationChannel.findMany` and `loadEventStatuses` in parallel via `Promise.all`, returning `{ channels, events }`. Backwards-compatible: the Settings card consumer destructures `data.channels` and ignores `events`.
- New ledger sources are additive — a future `MedicationReminderDispatch` table can wire into `loadEventStatuses` without changing the iOS contract.
## Tests
- `src/lib/notifications/senders/__tests__/apns.test.ts` extended with 3 new tests (8 cases via `it.each`):
- `MEDICATION_REMINDER sets interruption-level=time-sensitive + priority=10`.
- parameterised: 6 other event-types (`MOOD_REMINDER`, `MEASUREMENT_ANOMALY`, `COMPLIANCE_LOW`, `WITHINGS_SYNC_FAILED`, `SYSTEM_ALERT`, `PERSONAL_RECORD`) MUST omit `interruptionLevel` and `priority` (Focus respected).
- `sendApnsPush` round-trips explicit `interruptionLevel` + `priority`.
- New file `src/app/api/notifications/status/__tests__/route.test.ts` (181 lines, 4 tests):
- 401 when unauthenticated; no DB lookup before auth.
- Empty-state user gets every known event-type in the map with `lastDeliveredAt: null`.
- `MOOD_REMINDER.lastDeliveredAt` reads the latest `MoodReminderDispatch` row and is scoped to the calling user.
- `channels` array shape preserved alongside new `events` field (backwards compat).
## Quality gates
- `pnpm typecheck` — clean.
- `pnpm lint` — pre-existing errors in `src/app/privacy/page.tsx` + a consent route warning, none in my file set.
- Targeted tests — `src/lib/notifications/senders/__tests__/apns.test.ts` + `src/app/api/notifications/status/__tests__/route.test.ts` → 38 passed.
- Full `src/lib/notifications/` suite — 85 passed across 6 files.
## Commits on `develop`
- `8187d549 feat(apns): time-sensitive interruption-level for medication reminders` — my SB-5 commit. Pre-commit hook auto-staged 8 parallel-wave files that were already in the working tree; this is the same `cross-agent commit-message drift` pattern memorised from v1.4.37.
- `1bcaae47 fix(dashboard-summary): exclude soft-deleted in sparkline and streak queries` — SB-6 work landed inside a parallel wave's commit when their pre-commit hook ran before mine and folded my staged changes into their commit body. Diff confirms my route.ts changes (+67 lines) + the new test file (+181 lines) are intact. Marc should be aware the commit message does not reflect the full diff — the release-notes / CHANGELOG sweep needs to pull SB-6 details from this commit too.
## AP-2 dependency callout (release notes)
- **SB-5 has no observable effect until the `.p8` APNs key is installed on production.** The dispatcher gates every send on `loadApnsConfig()` returning non-null, which requires `APNS_KEY_ID`, `APNS_TEAM_ID`, `APNS_BUNDLE_ID`, and one of `APNS_KEY` / `APNS_KEY_FILE` to be set together. Until Marc lands the .p8 key in the Coolify env, every dispatch returns `apns_not_configured` and the new `interruption-level` field never leaves the server.
- The v1.4.40 release notes should carry this caveat verbatim so a future "why doesn't medication-reminder break through Focus?" support thread points straight at the env-var gap rather than the code.
## Source-of-truth gap for SB-6 (future work)
- Only `MOOD_REMINDER` has a per-event ledger today (`MoodReminderDispatch`). The other six event-types return `null` until a per-event dispatch ledger lands. The cleanest follow-up is a `NotificationDispatch` table (or dispatcher-side hook into `recordChannelSuccess`) that records `{ userId, eventType, dispatchedAt }` on every successful send. Out of scope for v1.4.40 — flagged in the wave brief as "drives the iOS NotificationsScreen 'last-delivered Xh ago' line", which the iOS team can render against `null` rows as "Never" until the ledger expands.
+83
View File
@@ -0,0 +1,83 @@
# Phase W-CONSENT — v1.4.40 marathon report
## Scope
SB-10 from `SERVER-BACKLOG.md`. App-Store Guideline 5.1.2(i) + GDPR
Art. 7 audit trail for AI consent. Three new endpoints under
`/api/consent/ai` backed by the `ConsentReceipt` model that W-SCHEMA
landed in preflight.
## Deliverables
### New files
- `src/lib/validations/consent.ts` — Zod schemas. `consentKindEnum`
covers `ai_full` / `ai_insights_only` / `ai_coach`. `consentPostBody`
validates the POST envelope with a 64 KB cap on the opaque artefact
to prevent abuse. `consentLatestQuery` covers GET + DELETE.
- `src/lib/consent/receipts.ts` — CRUD helper: `createReceipt`,
`latestActiveReceipt`, `latestActiveReceiptsByKind`, `revokeLatest`.
Append-only; revoke flips `revokedAt` only. No `delete` path.
- `src/app/api/consent/ai/route.ts` — `POST` handler. Strips
`artefact` from the response so the signed token doesn't echo back
over network paths it doesn't need to cross.
- `src/app/api/consent/ai/latest/route.ts` — `GET` + `DELETE`.
- GET with `?kind` returns the latest active row or null.
- GET without `?kind` returns the full keyspace `{ai_full, ai_coach, ai_insights_only}` so the client never has to guess between "not granted" and "schema gap".
- DELETE with `?kind` flips `revokedAt`. Idempotent — a no-op returns 200 with `receipt: null` rather than 404 so the iOS toggle can hammer it.
- DELETE without `?kind` is the master "AI deaktivieren" sweep across every kind.
- `src/app/api/consent/ai/__tests__/route.test.ts` — POST coverage.
- `src/app/api/consent/ai/latest/__tests__/route.test.ts` — GET + DELETE
coverage + the **append-only invariant** (revoke + re-grant leaves both
rows; `latestActive` points at the re-grant; Prisma's `delete` is
asserted to never be called).
- `src/lib/consent/__tests__/receipts.test.ts` — helper-level tests for
every CRUD function.
## Quality gates
- `pnpm typecheck` — clean.
- `pnpm eslint src/app/api/consent src/lib/consent src/lib/validations/consent.ts` — clean on my files.
- Consent test suite: **21/21 passing** in 248 ms.
- Pre-commit OpenAPI gate fired and passed on all three commits.
## Commits (Marc-voice English, on `develop`)
1. `8692a97e` — `feat(consent): CRUD helper for AI consent receipts`
2. `d6809e3d` — `feat(consent): POST + GET + DELETE endpoints at /api/consent/ai`
3. `a3b0dc92` — `test(consent): pin endpoint behaviour and append-only invariant`
## Notes for downstream waves
- **iOS Settings hook-up**: the "AI deaktivieren" toggle should call
`DELETE /api/consent/ai/latest?kind=ai_full` (per-surface) or
`DELETE /api/consent/ai/latest` (master). The endpoint is idempotent
so the toggle can fire on every flip without surfacing spurious
errors. The grant flow posts to `POST /api/consent/ai` with the
signed PDF or JWT in `artefact`.
- **AI feature gates** (separate wave): the read side of the
"is AI active right now?" check should call
`latestActiveReceipt(userId, kind)` → non-null. Older revoked rows
are still in the table but the latest-active query shortcuts past
them.
- **Audit log**: both grant + revoke write `consent.ai.grant` /
`consent.ai.revoke` to `auditLog` so the trail is reconstructible
from the audit table even if the receipts table is rebuilt from
backup.
## Caveats observed during the wave
- `vi.resetAllMocks()` clears the implementation on top-level
`vi.mock(...).mockResolvedValue(undefined)` setups. The route's
fire-and-forget `auditLog(...).catch(() => {})` chain NPEs when the
mock returns `undefined`. The fix is to re-arm `auditLog` inside
`beforeEach`. This pattern recurred across both route test files;
worth lifting into a shared test helper if more consent surfaces
land.
- Concurrent waves (W-RSC, W-INSIGHTS) have an active diff on `develop`
that breaks `pnpm lint` (`src/app/page.tsx:577`) and `pnpm test`
(9 failures in `src/lib/insights/__tests__/features.test.ts` plus
some `targets`/`comprehensive` route tests). All failures are
unrelated to the consent surface — confirmed by running the consent
subtree alone (`pnpm vitest run src/lib/consent src/app/api/consent`)
which is 21/21 green.
+65
View File
@@ -0,0 +1,65 @@
# Phase W-DELETED — v1.4.40 — Soft-delete invisibility across every reader tier
## Goal
Close Senior Infra+DB audit **Critical Finding #3** (`.planning/round-v1439-arch-qa-infra-db.md`): `Measurement.deletedAt` was set by the iOS sync path but ignored by every read path. Once iOS soft-deletes its first reading, the tombstoned row would still contribute to dashboards, analytics fan-outs, the rollup populator's count, and the Coach snapshot the model grounds against.
## Inventory — read paths in scope of this phase
| File | Read shape | Action |
|---|---|---|
| `src/lib/measurements/rollups.ts` | `runRollupAggregate` SQL (DAY/WEEK/MONTH/YEAR rebuilds), `isRollupFresh` findFirst, `ensureUserRollupsFreshImpl` findFirst, boot-backfill discovery SQL | Added `AND m."deleted_at" IS NULL` to both branches of the aggregate, `deletedAt: null` to the two `findFirst` watermark probes, and `WHERE m."deleted_at" IS NULL` to the discovery's measurements scan |
| `src/lib/measurements/rollup-coverage.ts` | `probeRollupCoverage` discovers which types the user has logged | Added `AND "deleted_at" IS NULL` so tombstoned-only types stop demanding coverage and pushing reads onto the live aggregator |
| `src/lib/analytics/summaries-slice.ts` | 4× `$queryRaw` (narrow + DISTINCT ON latest, mirrored across the rollup-fresh and cold-fallback branches) | Added `AND m."deleted_at" IS NULL` to all four. The DAY-bucket aggregate read from `measurement_rollups` is unaffected — it inherits the writer-side filter |
| `src/lib/analytics/correlations-fast-path.ts` | `fetchSeriesChunked` cursor-paged findMany | Added `deletedAt: null` to the where clause |
| `src/lib/analytics/bp-in-target-fast-path.ts` | `fetchSeriesChunked` cursor-paged findMany | Added `deletedAt: null` to the where clause |
| `src/lib/analytics/health-score-fast-path.ts` | 3× `prisma.measurement.findMany` (WEIGHT source attribution, WEIGHT live-fallback series, BLOOD_PRESSURE_SYS source attribution) | Added `deletedAt: null` to each |
| `src/lib/insights/comprehensive-aggregator.ts` | 8× `FROM measurements m` (window_stats CTE + main aggregate + DISTINCT ON latest + firstAt probe, mirrored across rollup-fresh + cold-fallback) + 1× `fetchBpRawRows` findMany | Added `AND m."deleted_at" IS NULL` to all 8 SQL filters + `deletedAt: null` to the BP findMany |
| `src/app/api/dashboard/summary/route.ts` | `latestIn7d` DISTINCT ON SQL, `allTime` groupBy, `streakDays` SQL | Added `AND m."deleted_at" IS NULL` to the two raw queries + `deletedAt: null` to the groupBy. `sparkBuckets` reads from `measurement_rollups` (writer-side filter) |
| `src/app/api/measurements/route.ts` | Shared `where` builder used by main findMany + count + groupBy=day; dayKey drill-down findMany; rollup-coverage day-count probe; live `date_trunc` aggregate | Added `deletedAt: null` to the shared `where` (inherits across all consumers), to the drill-down `findMany`, and `AND m."deleted_at" IS NULL` to the two raw SQL clauses |
| `src/app/api/measurements/series/route.ts` | 3× findMany (BP_SYS / BP_DIA pair + single-type) | Added `deletedAt: null` to all three |
| `src/lib/ai/coach/snapshot.ts` | Consolidated `prisma.measurement.findMany` over the active metric types for the AI Coach prompt | Added `deletedAt: null` so tombstoned data never reaches the model |
## NOT touched (owned by other agents)
- `src/app/api/analytics/route.ts` (W-POOL)
- `src/app/api/insights/**` (W-INSIGHTS) — `cards/route.ts`, `generate/route.ts`, `targets/route.ts`, `comprehensive/route.ts`
- `src/lib/insights/features.ts` (W-INSIGHTS — actively modifying for the mood-rollup swap)
- `src/lib/insights/glp1-plateau.ts`, `pulse-status.ts` (W-INSIGHTS engine helpers — left for that agent to filter when they re-touch)
- `src/app/api/sync/state/route.ts` (already filters per the audit)
- Read-only export / admin / doctor-report / gamification / reminder-worker / pr-detection-worker / withings paths (out of scope for the audit's critical finding; deferred to W-DELETED v1.4.41 if needed — they don't affect the user-facing dashboard or analytics surfaces)
## File path was non-existent
`src/app/api/measurements/timeline/route.ts` — no such route in the codebase. The dispatch file-set name was prospective.
## Test matrix
`tests/integration/measurement-soft-delete.test.ts` pins three end-to-end contracts against the Postgres testcontainer (all three pass):
| Tier | Fixture | Assertion |
|---|---|---|
| Analytics summaries slice | 3× WEIGHT readings (80.0 / 99.0 / 80.2). Soft-delete the 99.0 sentinel. | `GET /api/analytics?slice=summaries` returns `WEIGHT.count == 2`, `latest == 80.2`, `min == 80.0`, `max == 80.2`, `mean == 80.1`. If the tombstone leaked, `max` would explode to 99.0. |
| Dashboard summary | 2× WEIGHT readings (80.0 1d ago + 99.0 6h ago). Soft-delete the 99.0. | `GET /api/dashboard/summary` returns the WEIGHT tile with `latestValue == 80.0`, `allTimeCount == 1`. If the tombstone leaked, `latestValue` would be 99.0. |
| Rollup recompute | 3× same-day WEIGHT readings (80.0 / 99.0 / 80.4). Soft-delete the 99.0. Call `recomputeBucketsForMeasurement`. | Resulting DAY rollup bucket has `count == 2`, `mean == 80.2`, `min == 80.0`, `max == 80.4`. If the tombstone leaked, `count == 3` and `mean ≈ 86.5`. |
Targeted unit tests: 454/454 passing across `src/lib/measurements`, `src/lib/analytics`, `src/lib/insights/__tests__/comprehensive-aggregator.test.ts`, `src/app/api/dashboard`, `src/app/api/measurements`.
Full unit suite: 4731/4737 passing; the 5 failures live in `src/lib/insights/__tests__/features.test.ts` and are caused by W-INSIGHTS' in-flight mood-rollup swap — confirmed unrelated to this phase by stashing their `features.ts` changes and re-running (test passes).
Lint: 1 error in `src/app/page.tsx` shipped by W-RSC's Suspense-boundary commit — unrelated.
Typecheck: clean.
## Commits (atomic, on `develop`)
- `db2d864a` — `fix(rollups): skip soft-deleted measurements in DAY recompute`
- `f0a0551a` — `fix(rollups): filter deletedAt in measurement-rollup readers`
- `1f490410` — `fix(summaries-slice): exclude soft-deleted measurements`
- `37342744` — `fix(correlations,bp-fast-path,health-score): exclude soft-deleted measurements`
- `c47b7dad` — `fix(comprehensive-aggregator): exclude soft-deleted in raw aggregate`
- `1bcaae47` — `fix(dashboard-summary): exclude soft-deleted in sparkline and streak queries`
- `97920f0f` — `fix(measurements-route,series,timeline): exclude soft-deleted in list reads`
- `55fefb1d` — `test(integration): pin soft-delete invisibility across analytics, dashboard, rollup tiers`
(`1bcaae47` was inflated by the openapi pre-commit hook to also carry an unrelated `notifications/status` route + test from another agent; the dashboard-summary diff stands on its own.)
+189
View File
@@ -0,0 +1,189 @@
# Phase W-GHOSTS — Dead-code purge (v1.4.40)
Owner: W-GHOSTS. Ran in parallel with W-WMY-WIRE. Touch-disjoint
verified per commit; W-WMY-WIRE's in-flight diffs were never staged.
## Deletions landed
Nine atomic commits on `develop` (oldest → newest):
| Commit | Subject | Net |
| --------- | ---------------------------------------------------------------------------------------- | ----- |
| 0ec4a993 | chore(jobs): remove dead TELEGRAM_CLEANUP_QUEUE handler and registration | -44 |
| 2b4e2177 | chore(routes): remove orphan /api/medications/intake-summary endpoint | -107 |
| 2bff80aa | chore(routes): remove duplicate /api/monitoring/{umami,glitchtip}/test endpoints | -550 |
| 9df23c3c | chore(analytics): drop unreferenced movingAverage and weeklyAverages exports | -173 |
| 75607e4c | chore(measurements): drop unreferenced typesMissingCoverage / readCumulativeDaySumsBatch | -119 |
| f3a4da50 | chore(glucose,berlin-day): drop unreferenced mmolToMgdl and berlinDayKey exports | -86 |
| e26e27b5 | chore(i18n): drop dead format.dateShort timeShort dateTime keys across six locales | -99 |
| 79c86a06 | refactor(tz): consolidate startOfUtcDay into src/lib/tz/start-of-utc-day.ts | -1 |
| cd8277d3 | refactor(tz): consolidate wallClockInTz into src/lib/tz/wall-clock.ts | +2 |
Net source lines removed: ≈ 1 177.
### TELEGRAM_CLEANUP_QUEUE (commit 0ec4a993)
`src/lib/jobs/reminder-worker.ts` — dropped:
- `const TELEGRAM_CLEANUP_QUEUE = "telegram-message-cleanup"` (was line
164)
- `interface TelegramCleanupPayload` (was line 281)
- `async function handleTelegramCleanup` (was line 1098)
- The entry in `allQueues` (was line 1672)
- The `boss.work<TelegramCleanupPayload>(TELEGRAM_CLEANUP_QUEUE, …)`
bind (was line 1832)
Zero `boss.send(TELEGRAM_CLEANUP_QUEUE, …)` callers anywhere — the
queue would have received zero jobs in production. `deleteMessage`
from `@/lib/telegram` is still imported because the live moodlog-mood
deletion path at `:379` uses it.
### /api/medications/intake-summary (commit 2b4e2177)
- Deleted `src/app/api/medications/intake-summary/route.ts`
- Removed `medicationIntakeSummary` from the queryKey factory in
`src/lib/query-keys.ts:90`
- Removed the corresponding entry from `medicationDependentKeys`
(would have invalidated a never-rendered key)
- Cleaned a stale doc reference in `medication-intake-quick-add.tsx`
### /api/monitoring/{umami,glitchtip}/test (commit 2bff80aa)
Deleted both routes plus their `__tests__/route.test.ts` files. The
admin UI calls the `/api/admin/monitoring/{umami,glitchtip}-test`
counterparts via `umami-section.tsx:41` and `glitchtip-section.tsx:38`
respectively — those routes remain untouched.
### 9 dead exports — 7 deleted, 2 kept
Deleted (with their dedicated tests where they existed):
- `movingAverage` (`src/lib/analytics/trends.ts`) — `health-chart.tsx`
and `mood-chart.tsx` implement their own `movingAverageByPoints`
locally; the lib helper had no production consumer.
- `weeklyAverages` (`src/lib/analytics/correlations.ts`) — only the
unit test consumed it. The Berlin-tz `BERLIN_DATE_PARTS` constant
fell out as a tag-along since `weeklyAverages` was its only reader.
- `typesMissingCoverage` (`src/lib/measurements/rollup-coverage.ts`)
— the rollup-fast-path probes coverage via `coverage.get(type)` or
the all-or-nothing `isFullyCovered` helper.
- `readCumulativeDaySumsBatch`
(`src/lib/measurements/rollup-read-cumulative.ts`) — only its own
test referenced it; the live A2 path took the chunked per-type
`findMany` route via `readCumulativeDaySums` (singular).
- `mmolToMgdl` (`src/lib/glucose.ts`) — UI only converts mg/dL →
mmol/L for display via `convertGlucose`; the reverse helper was
unused.
- `berlinDayKey` (`src/lib/analytics/berlin-day.ts`) — every display
surface migrated to `userDayKey(date, tz)` in v1.4.38. Whole module
+ dedicated test removed. Updated stale comments in
`src/app/api/analytics/route.ts:212` and `src/lib/tz/format.ts`.
Kept (false positives — flagged as dead but live):
- **`detectAnomalies`** (`src/lib/analytics/trends.ts:119`) — the
Ghost-Hunter audit listed it as "test-only", but `summarize()` in
the same file calls `detectAnomalies(data).length` at line 260, and
`summarize` is imported by `/api/analytics`, `/api/insights/cards`,
`/api/insights/comprehensive`, `/api/insights/generate`,
`/api/mood/analytics`, `/api/measurements/series`, and
`src/lib/insights/features.ts`. Reachable transitively from the
production read path — kept.
- **`ensureUserMedicationComplianceFresh`**
(`src/lib/medications/compliance-rollups.ts:492`) — the
W-GHOSTS brief listed it in the "DO NOT touch" set with the note
that W-INSIGHTS is wiring it in parallel. Grep currently shows only
the export definition; W-INSIGHTS' phase report references the
recompute hook. Held per directive, deferred to a follow-up audit
once both waves have settled.
- **`readWeekRollups` / `readMonthRollups` / `readYearRollups`**
(`src/lib/measurements/rollup-read-wmy.ts:125-149`) — explicitly
excluded from this wave per the brief (W-WMY-WIRE was wiring them
during this run).
### Dead i18n keys — `format.{dateShort,timeShort,dateTime}` × 6 locales
Removed the `format` namespace block from `messages/de.json`,
`messages/en.json`, `messages/es.json`, `messages/fr.json`,
`messages/it.json`, `messages/pl.json`. Deleted the dedicated pinning
test at `src/lib/i18n/__tests__/format-locale-order.test.ts` — it was
the only consumer of those keys.
Verified the locale-parity tests under `src/__tests__/i18n-drift-guard.test.ts`
and `src/lib/i18n/__tests__/fallback-chain.test.tsx` still pass. They
do not walk a hard-coded key list — they assert structural parity per
locale, so the synchronous removal across all six bundles keeps them
green.
### Duplicate helpers consolidated
#### `startOfUtcDay` → `src/lib/tz/start-of-utc-day.ts` (new file, commit 79c86a06)
The byte-identical helper that lived as a private function in:
- `src/lib/measurements/rollups.ts:524` (removed)
- `src/lib/mood/rollups.ts:620` (removed)
now lives once at `src/lib/tz/start-of-utc-day.ts`. Both callers
import `{ startOfUtcDay } from "@/lib/tz/start-of-utc-day"`. The
function body, public contract, and call sites are unchanged.
#### `wallClockInTz` → `src/lib/tz/wall-clock.ts` (new file, commit cd8277d3)
Two file-local copies with diverging return shapes:
- `src/lib/medications/compliance-rollups.ts:73` returned only
`{year, month, day}` and required `tz: string`.
- `src/lib/medications/scheduling/cadence.ts:107` returned the full
`{year, month, day, hour, minute, second, weekday}` tuple and
accepted `tz: string | undefined`.
Canonical version returns the full tuple (super-set), accepts
`tz: string | undefined`, and lives in `src/lib/tz/wall-clock.ts`.
`compliance-rollups.ts` destructures the three fields it needs at
line 172 (`parts.year`, `parts.month`, `parts.day`) so the existing
call site stays one line. The cross-tz ±3 h guard now reads from a
single helper instead of two siblings that could drift.
Two additional `wallClockInTz`-named helpers exist in
`@/lib/tz/format.ts` (`wallClockParts`) but those carry a different
signature (returns string-typed parts) and a different responsibility
(formatting). Out of scope for this consolidation — and the brief
called for the two `medications/`-folder copies specifically.
## False positives kept (re-stated, single list for the auditor)
| Symbol | Why kept |
| ------------------------------------- | --------------------------------------------------------------- |
| `detectAnomalies` | Transitively used by `summarize()`, which the analytics / insights / measurements-series routes consume |
| `ensureUserMedicationComplianceFresh` | Brief excludes it; W-INSIGHTS wiring claim pending verification |
| `readWeekRollups` etc. | Brief excludes them; W-WMY-WIRE is the consumer wave |
## Quality gates
- `pnpm typecheck` clean at every commit (verified after each).
- Targeted vitest sweep across 75 test files (1 037 tests, 1 skipped)
passed after the last commit.
- `pnpm lint` flags a pre-existing `react-hooks/preserve-manual-memoization`
error in `src/app/page.tsx:577` (commit 3cacfcf9, untouched by this
wave) — not introduced here.
## Cross-wave drift observed
W-WMY-WIRE landed `summaries-slice.ts`, `health-score-fast-path.ts`,
and `health-score-fast-path.test.ts` edits mid-run. None of those files
overlap with the W-GHOSTS touch list; each pre-commit was preceded by
`git status` and a targeted `git add` of only the W-GHOSTS files. One
race (between `0ec4a993` and `24568c80`) briefly mis-attached the
intake-summary deletion to a W-WMY-WIRE commit message — the commit
that actually carries the intake-summary deletion in HEAD is
`2b4e2177` and is correctly titled. No data loss.
## Tag-along cleanup
- `src/app/api/analytics/route.ts:212` — comment updated to reflect
the `berlinDayKey()` retirement.
- `src/lib/tz/format.ts:124` + `:157` — same.
- `src/components/dashboard/medication-intake-quick-add.tsx:61` —
removed the dead "intake-summary" entry from the doc-comment list of
invalidated query keys.
+163
View File
@@ -0,0 +1,163 @@
# Phase W-INFRA — v1.4.40 marathon
Owner: W-INFRA. Phase D, sequential after W-GHOSTS. Four threads
shipped on `develop`. `pnpm typecheck` clean after every commit.
`pnpm lint` clean after the page.tsx fix. All 93 rollup tests green
post-restructure.
## Thread 1 — analytics route deletedAt gap (commit `fa368170`)
`src/app/api/analytics/route.ts` had three soft-delete leaks W-DELETED
skipped (assumption: W-POOL would catch them; W-POOL only touched
concurrency, not predicates):
| Call site | Body |
| --- | --- |
| `:507` | per-context glucose summaries (`FASTING` / `POSTPRANDIAL` / `RANDOM` / `BEDTIME`) |
| `:615` | sleep-stage breakdown (last 30 d, `sleepStage` non-null) |
| `:725` | `fetchMeasurementSeriesChunked` — the paged read backing the per-type `summarize()` loop **and** the BP-Zielbereich windowing **and** the deprecated correlation hypothesis path |
Added `deletedAt: null` to all three. The chunked helper is the
load-bearing one: every analytics tile derived from `summarize()`
(slope7/30/90, anomaly z-scores) was still pulling tombstoned rows
into trend math.
## Thread 2 — page.tsx lint regression (commit `8974e773`)
`src/app/page.tsx:577` — W-RSC commit `3cacfcf9` introduced a
`useMemo(() => …, [user?.timezone])`. React Compiler's
`preserve-manual-memoization` rule refused to optimise the surrounding
1 400-line component body because the inferred dep is the wider `user`
object — broader than the literal `user?.timezone` declared in the
dep array. Compiler "inferred less specific property than source"
error fires whenever an optional-chain expression spans the inference
boundary.
Fix: lift `const userTimezone = user?.timezone` to a local **before**
the memo, then key the memo on the local. Inferred + declared deps
agree and the compiler resumes optimising. No runtime change.
## Thread 3 — src/lib/rollups/ umbrella (5 commits)
Five atomic moves with typecheck between each:
| Commit | Files moved |
| --- | --- |
| `29bfcc67` | `measurements/rollups.ts` → `rollups/measurement-rollups.ts` + co-located test |
| `b7da526c` | `measurements/rollup-read.ts` → `rollups/measurement-read.ts`; `measurements/rollup-coverage.ts` → `rollups/measurement-coverage.ts` + tests |
| `8cfb1715` | `measurements/rollup-read-wmy.ts` → `rollups/measurement-read-wmy.ts`; `measurements/rollup-read-cumulative.ts` → `rollups/measurement-read-cumulative.ts` + tests |
| `63e56f7a` | `mood/rollups.ts` → `rollups/mood-rollups.ts` + co-located test |
| `cc08a43e` | `medications/compliance-rollups.ts` → `rollups/medication-compliance-rollups.ts` + co-located test |
### Before / after map
```
src/lib/measurements/ src/lib/rollups/
├── rollups.ts ├── measurement-rollups.ts
├── rollup-read.ts ├── measurement-read.ts
├── rollup-read-wmy.ts ├── measurement-read-wmy.ts
├── rollup-read-cumulative.ts ├── measurement-read-cumulative.ts
├── rollup-coverage.ts --> ├── measurement-coverage.ts
├── …other measurement helpers ├── mood-rollups.ts
├── medication-compliance-rollups.ts
src/lib/mood/ └── __tests__/
├── rollups.ts ├── measurement-rollups.test.ts
├── …other mood helpers ├── measurement-read.test.ts
├── measurement-read-wmy.test.ts
src/lib/medications/ ├── measurement-read-cumulative.test.ts
├── compliance-rollups.ts ├── mood-rollups.test.ts
├── …other med helpers └── medication-compliance-rollups.test.ts
```
### Importer count rewritten
- `@/lib/measurements/rollups` → `@/lib/rollups/measurement-rollups`: 27 sites
- `@/lib/measurements/rollup-read` → `@/lib/rollups/measurement-read`: 2 sites
- `@/lib/measurements/rollup-coverage` → `@/lib/rollups/measurement-coverage`: 10 sites
- `@/lib/measurements/rollup-read-wmy` → `@/lib/rollups/measurement-read-wmy`: 2 sites
- `@/lib/measurements/rollup-read-cumulative` → `@/lib/rollups/measurement-read-cumulative`: 0 external (co-located test only)
- `@/lib/mood/rollups` → `@/lib/rollups/mood-rollups`: 17 sites
- `@/lib/medications/compliance-rollups` → `@/lib/rollups/medication-compliance-rollups`: 10 sites
Sibling-relative imports promoted to the `@/lib` alias where the move
crossed a directory boundary:
- `measurements/rollup-read.ts`'s `./rollups` →
`@/lib/rollups/measurement-rollups` (fixed pre-move so the chain
stays type-clean through the in-flight restructure)
- `rollups/measurement-read-cumulative.ts`'s `./apple-health-mapping`
→ `@/lib/measurements/apple-health-mapping` (apple-health-mapping
stays in measurements/ — it isn't a rollup, just the canonical type
mapping the cumulative reader consults)
Pure relocation: zero behaviour, zero exported-surface change. The
umbrella exists for discoverability + contract clarity before the iOS
sprint locks the rollup boundary as the read-path contract.
## Thread 4 — knip CI gate (commit `cbf55461`)
`pnpm add -D knip` (6.14.1). New `knip.json` + new
`.github/workflows/knip.yml` triggered on `push` to `main` and on PRs
targeting `main`. Gate runs
`pnpm knip --reporter compact --include files,dependencies,binaries,unlisted`
— `--include` restricted to the four categories where a new violation
genuinely means dead code is about to ship.
`exports` + `types` are deliberately omitted from the failure set this
release. The W-GHOSTS sweep cleared 1 177 lines of dead code but the
historic surface still shows 487 unused exports + 52 unused types,
which would have made the gate red on day one. Those categories should
become enforcing in a future wave, one module slice at a time.
### Baseline whitelist (knip.json)
`ignore` — three files W-GHOSTS' nine-commit sweep missed. Kept as
known-zero callers until a follow-up wave can verify they're safe to
delete together with their dedicated tests:
- `e2e/setup/test-helpers.ts` — 9-line barrel re-export; comment notes
it's reserved for future multi-user spec helpers.
- `src/components/charts/compliance-line-chart.tsx` — only referenced
from `__tests__/touch-action-guard.test.ts` (a contract test that
expects the file to exist) and comment-only mentions in
`chart-tokens.ts` + its test.
- `src/lib/logging/index.ts` — 21-line barrel re-export; never imported.
`ignoreDependencies`:
| Package | Why knip cannot see the consumer |
| --- | --- |
| `@hookform/resolvers`, `react-hook-form` | Dynamic import via `next/dynamic` + form-builder pattern |
| `@prisma/client` | Re-exported through `src/generated/` which is in `ignore` |
| `next-themes` | `ThemeProvider` is mounted via `next/dynamic` in `layout.tsx` |
| `@vitejs/plugin-react` | `vitest.config.ts` only |
| `shadcn` | CLI consumed by `pnpm dlx shadcn` invocations |
| `tailwindcss`, `tw-animate-css` | PostCSS pipeline + `@theme inline` directive in `globals.css` |
| `testcontainers` | Bootstrapped by `tests/integration/setup.ts` |
| `@types/node`, `@types/react`, `@types/react-dom` | Ambient typings |
`ignoreBinaries`:
| Binary | Why |
| --- | --- |
| `docker`, `docker-compose`, `pg_isready` | Invoked via shell from integration helpers (not from `package.json` scripts) |
| `tsx` | Reached via `npx tsx` in `openapi:generate` + `openapi:check` |
## Items deferred to v1.4.41 (per directive)
- `src/types/` DTO promotion (Org-audit rec #2) — out of scope to
avoid clashing with in-flight imports.
- Prompt directory unification (Org-audit rec #3) — touches AI lib
organisation; needs its own wave.
- ESLint custom rule for queryKey factory — W-RSC report deferred this.
- Delete-rather-than-whitelist for the three `ignore`d files above
once a future wave can also remove their dedicated tests.
- Enforce knip on `exports` + `types` once the 487 / 52 historic
backlog is cleared incrementally.
## Verification
- `pnpm typecheck` — clean after every commit (9 commits, 9 runs).
- `pnpm lint` — clean after the page.tsx fix.
- `pnpm vitest run src/lib/rollups/__tests__` — 6 files / 93 tests passed.
- `pnpm knip --reporter compact --include files,dependencies,binaries,unlisted` — zero output, exit 0.
+185
View File
@@ -0,0 +1,185 @@
# Phase W-INSIGHTS — v1.4.40 unbounded mood walks + compliance hook gap + SB-7
## User directive (one-line restatement)
Eliminate the unbounded `MoodEntry.findMany` walks across the Insights
cluster + close the v1.4.39.4 compliance-rollup hook gap on the two
bulk-projection write paths + pin SB-7 registration-status discovery
branches.
## Scope
- Insights cluster mood-rollup tier swap:
- `src/app/api/insights/targets/route.ts`
- `src/app/api/insights/comprehensive/route.ts`
- `src/lib/insights/features.ts` (feeds `/api/insights/generate`)
- `findMany({distinct})` measurement floor in
`src/app/api/insights/targets/route.ts:191`.
- Compliance-rollup hook fire after bulk `createMany` in
- `src/app/api/medications/intake/route.ts` (`scope=today` branch)
- `src/app/api/dashboard/summary/route.ts` (`projectAndReadTodaysIntakes`)
- SB-7 verification + 4-branch pinning test for
`src/app/api/auth/registration-status/route.ts`.
## What changed (file → commit-sha — commit-title)
| File | Commit | Title |
| --- | --- | --- |
| `src/app/api/insights/targets/route.ts` | `f8de4b05` | `perf(insights-targets): consume mood-rollup tier and floor distinct measurement scan` |
| `src/app/api/insights/targets/__tests__/route.test.ts` (new) | `f8de4b05` | (same commit) |
| `src/app/api/insights/comprehensive/route.ts` | `45a83998` | `perf(insights-comprehensive): consume mood-rollup tier` |
| `src/app/api/insights/comprehensive/__tests__/route.test.ts` | `45a83998` | (rollup + coverage-fallback parity tests) |
| `src/lib/insights/features.ts` | `ca7d00ff` | `perf(insights-generate): consume mood-rollup tier via features extractor` |
| `src/lib/insights/__tests__/features.test.ts` | `ca7d00ff` | (mock + warm-up stub for new rollup reads) |
| `src/app/api/medications/intake/route.ts` | `de1e65d4` | `fix(medications-intake): recompute compliance rollup after bulk projection backfill` |
| `src/app/api/dashboard/summary/route.ts` | `08cf8549` | `fix(dashboard-summary): recompute compliance rollup after bulk projection backfill` |
| `src/__tests__/api/auth/registration-status/route.test.ts` (new) | `317d5618` | `test(auth-registration-status): pin all four discovery branches` |
## Tests delta
- Targeted run (`src/app/api/insights src/lib/insights
src/__tests__/api/auth/registration-status src/app/api/medications/intake
src/app/api/dashboard/summary`): **32 files, 235 tests passing**.
- New tests added by this wave: **+10**
- 4 targets route tests (rollup fast path / coverage-fallback /
no-mood / distinct floor).
- 2 comprehensive route tests (rollup fast path / coverage-fallback).
- 4 registration-status SB-7 branches (enabled / disabled / null /
fail-closed).
- Existing tests touched (mock surface extension): `features.test.ts`
(added `moodEntryRollup` mock + warm-up stub) + `comprehensive/route.test.ts`
(same). Both stay green at the original test counts plus the two
additions.
- Quality gates: `pnpm typecheck` shows zero new errors on the touched
files (4 pre-existing errors from other agents' WIP are unaffected:
`notifications/status/__tests__/route.test.ts`, `health-chart.tsx`,
`apns.ts`). `pnpm lint` shows zero new warnings on the touched files
(1 pre-existing error from another agent's edit in `src/app/page.tsx`
is unaffected).
## Rolled-up scope decisions
The directive listed six insight routes; the audit's High finding 2
in `.planning/round-v1439-arch-qa-infra-db.md` enumerates the six
mood-walk sites. Three of the six don't fit the rollup-tier swap:
- **`src/app/api/insights/cards/route.ts`** — has no mood query.
Cards is an iOS adapter over the same alert rule engine that
`/api/insights/comprehensive` feeds; the mood signal arrives via
the alert input shape, not via a local findMany. No change needed.
- **`src/app/api/insights/glp1-timeline/route.ts`** — reads
`tags: string[]` from `mood_entries` for the side-effect day
collapse. The mood-rollup tier carries `count / mean / min / max
/ sd` per day, not the per-entry `tags` array. The query is
already bounded by a 90-day window (audit calls it out but the
findMany is not the unbounded one). Swap deferred to v1.5
per-user-tz mood tier when tag aggregation can land alongside.
- **`src/app/api/gamification/achievements/route.ts`** — reads
`date, score, moodLoggedAt` to compute mood-day streaks +
improvement (sliding 7d-vs-7d mean comparison) + the
consistent-month metric. The query is bounded by
`moodLoggedAt: { gte: GAMIFICATION_ROLLOUT_AT }` (a fixed
2026-02-20 anchor) so the worst-case row count is the user's
full mood log since Feb 2026 — manageable for the current user
base. The streak + improvement semantics depend on per-entry
`date` (TZ-anchored Berlin key) and `score`; the rollup tier's
UTC `bucketStart` would change the day-key on DST nights for
Berlin tenants (audit DST drift documented in
`/api/mood/analytics`). Byte-parity is not achievable without
the v1.5 per-user-tz bucket migration. Deferred.
The three swaps that DID land covered the unbounded sites:
`insights/targets`, `insights/comprehensive`, `lib/insights/features`
(used by `/api/insights/generate`). The compliance-rollup hook gap +
SB-7 4-branch test landed as scoped.
## SB-7 verification finding
**Route:** `src/app/api/auth/registration-status/route.ts`
**Branches walked (4):**
1. **Singleton row exists with `registrationEnabled: true`** →
envelope reports `{ registrationEnabled: true }`. The happy
path for an open self-hosted deployment.
2. **Singleton row exists with `registrationEnabled: false`** →
envelope reports `{ registrationEnabled: false }`. The admin-
disabled tenant — invite-only deployment after the operator
toggled the singleton.
3. **Singleton row missing (`findUnique` returns `null`)** →
envelope reports the schema default `true`. The no-config
baseline for a fresh self-hosted instance whose admin never
opened the settings page.
4. **`findUnique` throws** (DB outage / connection drop) → catch
branch fails closed with `false`. Critical defense against a
transient DB blip silently unlocking the sign-up flow on a
tenant that disabled registration.
**Code-change verdict:** none required. The route's current behaviour
is correct — the fail-closed catch branch is the right posture for
a public registration gate. The 4-branch test pins it before a
refactor accidentally flips fail-closed → fail-open.
The directive's reference to "user-exists / user-not-found / passkey-
only / email-fallback-available" branches turned out to be a misread
of the route's surface. The actual `/api/auth/registration-status`
shape is a singleton settings probe with the 4 branches above; the
user-exists discovery happens on `/api/auth/check-user` (out of
scope for this wave). Documenting here so a future agent doesn't
chase the wrong route.
## Self-review findings + applied
1. **Rollup `bucketStart` UTC vs `MoodEntry.date` Berlin-anchored
day-key parity.** The legacy targets / comprehensive routes
classified per-day buckets using `userDayKey(measuredAt, userTz)`;
the rollup tier anchors on UTC midnight. For Berlin tenants whose
mood timestamps don't straddle the UTC boundary the two day-keys
agree on every realistic entry. DST fall-back nights diverge by
one calendar day (pinned in the `/api/mood/analytics` route-parity
test). Documented in the route-block doc comments + tests; the
v1.5 per-user-tz bucketing closes the gap.
2. **Coverage-fallback bound.** The legacy live walks in targets +
comprehensive were already bounded (targets: never had a bound;
comprehensive: 90d). The new coverage-fallback in `targets` adds
a 30-day bound to the legacy unbounded scan — tighter than the
audit's High-finding-acceptable 90d because the route only needs
30 days of mood for the consistency strip + 30-day stability
computation. The `features.ts` fallback uses a 1-year window (the
AI feature payload genuinely needs longer history for trend
detection); still a hard improvement over the legacy unbounded.
3. **Compliance recompute coalescing.** Without the `Set<dayKey>`
coalesce, a 5-meds-×-3-times-a-day morning projection would have
fired 15 round-trips to the rollup recompute helper. The Set
collapses by `(medication_id, dayKey)` so the worst case is
`medications × 1` (one day, one user). Mirror-applied to both
intake + dashboard call sites for symmetry.
4. **`findMany distinct` floor magnitude.** Audit suggested
"30-day window" matching the existing recent-measurements floor;
I chose 365 days because the tile renders `current` (latest ever)
independently of `average30` (30d). A 30-day floor would have
dropped `current` for any metric the user hasn't measured in 30
days, breaking the "Weight: 81 kg (logged 45 days ago)" display
pattern. 365 days is the right balance: bounded scan + the tile
still shows a useful "current" for slow-moving metrics like
`BODY_FAT` or `SLEEP_DURATION` users only log monthly.
## Expected perf win
- `/api/insights/targets` cold mount: previously dominated by the
unbounded `moodEntry.findMany` + the unbounded `findMany distinct`.
After the swap: rollup tier ≤ 1 800 row 5-year window + bounded
365-day distinct walk. Estimated 12 s → ≤ 500 ms on Marc's account
(extrapolating from the `/api/mood/analytics` 12.7 s → 200 ms gain
+ the audit's 347 k-row distinct sort fix).
- `/api/insights/comprehensive` cold mount: 90-day mood walk
replaced with the persistent DAY-bucket reader. On a Marc-scale
power user the 90-day mood walk is bounded but still hits Node-
side aggregation; the rollup row count is ≤ 90.
- `/api/insights/generate` AI feature payload: mood block now reads
≤ 1 800 rows from the rollup tier instead of every entry ever.
Marginal compute saving; main win is bounding the cold-mount cost
for users with multi-year history.
## Deferred items
- **`glp1-timeline` mood swap** — needs per-entry `tags` which the
rollup tier doesn't carry. v1.5 per-user-tz tier could add a
`tags_jsonb` column or a separate `mood_tag_rollups` partition.
- **`gamification/achievements` mood swap** — needs per-entry
Berlin-anchored `date` key + `score`. Byte-parity blocked until
v1.5 per-user-tz bucketing.
- **`/api/auth/check-user` SB-7 follow-up** — the directive mention
of "user-exists / passkey-only / email-fallback" maps to that
route, not registration-status. Out of scope for this wave; flag
for v1.4.41 backlog.
+114
View File
@@ -0,0 +1,114 @@
# W-POOL — v1.4.40 phase report
## Directive
Empirical-trace finding #1 from `.planning/round-v1439-empirical-trace.md`:
`/api/analytics` thick monopolises the Prisma pool for 6.5 s. 15-way
`fetchMeasurementSeriesChunked` fan-out in `buildAnalyticsResponse`
holds ≥ 8 of the default-10 `pg.Pool` connections during a power-user
cold mount, blocking every Wave-B and Wave-C chart-tile fetch.
Mitigations chosen (per trace §F1 + §F2):
1. Wrap the per-type `Promise.all` in `p-limit(4)` so analytics holds
at most 4 pool slots at any moment.
2. Raise `pg.Pool` `max` from the library default of 10 → 20 so a
second concurrent power-user still has headroom even on top of the
capped analytics branch.
## Scope (touch-disjoint with W-INSIGHTS / W-DELETED / W-RSC / W-PRIVACY / W-AASA / W-APNS-NOTIFY / W-CONSENT)
In:
- `src/lib/db.ts` (raised pool max + `getPoolMax()` resolver)
- `src/app/api/analytics/route.ts` (p-limit wrapper + cap export)
- `src/lib/__tests__/db.test.ts` (new — pool ceiling)
- `src/app/api/analytics/__tests__/route.test.ts` (extended — concurrency cap)
- `package.json` + `pnpm-lock.yaml` (`p-limit@7.3.0`)
Out (left alone, owned by sibling agents):
- `src/app/page.tsx` (W-RSC)
- `src/components/charts/mood-chart.tsx` (W-RSC)
- Anything under `src/app/api/insights/` (W-INSIGHTS)
- `src/app/.well-known/apple-app-site-association/route.ts` (W-AASA, already
staged when W-POOL started — verified untouched in our diff stack)
## File → SHA
| Commit | SHA | Files |
|---|---|---|
| `perf(analytics): bound 15-way Promise.all to 4 concurrent type-fetches` | `96b2ee4c` | `src/app/api/analytics/route.ts`, `package.json`, `pnpm-lock.yaml` |
| `perf(db): raise Prisma pg.Pool max to 20 for power-user fan-out headroom` | `3b8f4111` | `src/lib/db.ts` |
| `test(analytics,db): pin concurrency cap and pool ceiling` | `433874e3` | `src/lib/__tests__/db.test.ts` (new), `src/app/api/analytics/__tests__/route.test.ts` |
HEAD before W-POOL: `c9d5479b`. HEAD after W-POOL: `433874e3`.
(Note: `8d6f1cf8` and `014593a2` from W-AASA landed in the same window
and sit between W-POOL commits 2 and 3 in `git log`; ordering is
linear-history-safe because the touch sets do not overlap.)
## Implementation notes
**`src/lib/db.ts` — `getPoolMax()` env-resolver.**
Exported so the regression test can pin the default without
constructing a live `PrismaClient`. Default `20`, overridable via
`DATABASE_POOL_MAX`. Falls back to 20 on malformed or non-positive
values. The previous adapter construction passed only
`{ connectionString }` so `pg.Pool` silently used its library-default
ceiling of 10. The new shape passes `{ connectionString, max:
getPoolMax() }` — `pg.PoolConfig` accepts `max?: number` per
`@types/pg/index.d.ts:46-48`.
**`src/app/api/analytics/route.ts` — `ANALYTICS_TYPE_FETCH_CONCURRENCY`.**
Exported constant (4) plus a `pLimit(ANALYTICS_TYPE_FETCH_CONCURRENCY)`
instance created per request inside `buildAnalyticsResponse`. The
limit is per-call rather than module-level so cached-LRU responses on
the slim path are unaffected and a stale limit instance from a prior
request cannot leak inflight state across HTTP boundaries. The
existing `Promise.all` over `types.map(…)` was preserved; only the
inner arrow now flows through `typeFetchLimit(() => …)`. The
`fetchMeasurementSeriesChunked → .then(measurements => …)` body and
its return shape are untouched.
## Tests delta
Before: 4 tests in `src/app/api/analytics/__tests__/route.test.ts`,
1 test file in `src/lib/__tests__/` covering `db.ts` (`db-to-json.test.ts`
with 6 cases on the `toJson` helper only).
After:
- `src/lib/__tests__/db.test.ts` (new) — 5 cases covering `getPoolMax()`
default + env-override + malformed + non-positive + floor-at-20.
- `src/app/api/analytics/__tests__/route.test.ts` — 1 new case
("caps per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY")
that instruments the `findMany` mock with `inflight` / `peak`
counters and a 20 ms timer per call, asserting:
- `peak ≤ ANALYTICS_TYPE_FETCH_CONCURRENCY`
- `calls ≥ 8` (sanity)
- `40 ms ≤ elapsed < calls × 20 ms` (catches unbounded `Promise.all`
on the low end and accidental serialisation on the high end).
Full suite delta: + 6 unit tests (5 db + 1 analytics).
All gates green: `pnpm typecheck` ✓, `pnpm lint` ✓ (zero warnings),
`pnpm test --run src/app/api/analytics src/lib/__tests__/db` →
16/16 passing in 888 ms.
## Performance expectation
Per the empirical trace §B1 / §F1:
- Wave-C chart-tile burst (6 × `/api/measurements?source=rollup`) moves
from a single ~+7.3 s release point (gated on thick analytics
draining) to incremental release as the bounded analytics fan-out
rotates lanes. Expected first-paint of the chart row drops from
~7.3 s to ~1.6 s on the empirical fixture.
- Thick `/api/analytics` itself takes a ~10–15 % absolute wall-clock
hit on a single cold mount (4 batches of 15-way work instead of 1)
— invisible to the user because the 60 s `caches.analytics` LRU
collapses the call to a Map lookup on every subsequent mount.
- Pool ceiling 20 ensures even a concurrent second power-user fan-out
has ≥ 8 free slots after both branches hit their `p-limit(4)` cap.
## Deferred
None within W-POOL scope. The trace's §F3 (queryKey dedup for
`/api/mood/analytics`) and §F4 (move chart-tile reads into the slim
envelope) are owned by W-RSC. §F5 (defer version-poller) is out of
scope for this phase.
+163
View File
@@ -0,0 +1,163 @@
# Phase W-PRIVACY — v1.4.40 report
**Status:** complete. Both commits landed on `develop`
(`c79d9173` page + `128d8637` tests). Quality gates green.
**Scope:** SB-3 from the iOS team's server-backlog — public Next.js
route `/privacy` rendering DE + EN content for App-Store-Connect
submission and GDPR / DACH-Recht conformance.
---
## Pre-existing baseline (v1.4.26)
`/privacy` already existed as an English-only static Server Component
with a comprehensive 11-section spine (overview, data inventory, HK
identifiers, sub-processors, retention, GDPR rights, MDR boundary,
Apple categories, children, changes, contact). The 9-test contract in
`page.test.tsx` pinned the HealthKit identifier list and the
sub-processor roster.
---
## v1.4.40 deltas shipped
### 1. Page rewrite — bilingual DE + EN paired sections
`src/app/privacy/page.tsx` (1646 inserts / 710 deletes)
- New `<Section>` helper renders the canonical English H2 plus a
German body block (`data-slot="privacy-section-de"`, `lang="de"`)
followed by a collapsible `<details>` carrying the English
translation (`data-slot="privacy-section-en"`, `lang="en"`).
- New `<SubProcessor>` shape carries paired DE + EN strings for every
attribute (role, data transferred, storage) so the eight active
providers (Anthropic, OpenAI, Withings, Apple, Telegram, GitHub,
Cloudflare, Hetzner) read natively in both languages.
- H1 = "Datenschutzerklärung / Privacy Policy". TOC entries paired
("Overview / Überblick", "Erhobene Daten", etc.).
- Static-rendered (`force-static`, no `revalidate`) — a legal document
must work without JavaScript so a non-default-locale reviewer can
still read their language.
### 2. GDPR / SB-3 content additions
All nine SB-3 required elements landed verbatim, indexed by the brief:
| # | Requirement | Location in page |
| - | -------------------------------------- | --------------------------------------------------------------------------- |
| 1 | Data collected (BP / weight / mood / medication / AI prompts / push tokens) | sections 2.1–2.5, 2.8 |
| 2 | HK data flow (HK → iOS → POST /api/measurements over HTTPS) | section 2.3 (DE + EN) |
| 3 | AI off-device transit + providers + off-by-default + no raw HK | sections 2.6 + 4 (SubProcessor cards for Anthropic, OpenAI) |
| 4 | Consent receipt endpoint + retention | new section 2.7 (`GET /api/account/consents`, 5-year retention) |
| 5 | TLS 1.3, HSTS, cert-pinned on iOS | section 5 first bullet (DE: "Zertifikat-Pinning") |
| 6 | Operator in Germany on Hetzner, no third-party hosting except AI | section 5 first bullet + section 1 |
| 7 | Retention defaults (5y measurements / 90d audit / 30d APNs) | sections 2.9 + 5 |
| 8 | GDPR Art. 17 cascade through `User.delete` + `onDelete: Cascade` | section 6 (DE + EN, both name "User.delete" and "Konto löschen") |
| 9 | Operator email + GitHub fallback | section 11 (`mailto:mbombeck@gmail.com` + DSGVO subject line) |
`POLICY_VERSION` bumped 1.4.26 → 1.4.40, `LAST_UPDATED` → 2026-05-18.
### 3. Test contract expanded
`src/app/privacy/__tests__/page.test.tsx` — 9 tests → 17 tests. New
coverage:
- Bilingual title (DE + EN simultaneous).
- 11 paired `data-slot` markers + the language boundaries.
- HK data-flow path (`POST /api/measurements` + `TLS 1.3`).
- AI off-by-default + no raw HK identifiers + named providers.
- Consent endpoint + 5-year retention literal.
- TLS 1.3 + HSTS + cert pinning (DE mirror).
- Server location + operator framing.
- Retention defaults table (5y / 90d / 30d).
- Deletion route (`Settings → Daten → Konto löschen`, `User.delete`,
`onDelete: Cascade`).
- Operator email (mailto), PII rule still enforced.
---
## Decisions and trade-offs
### "via i18n" interpretation
The brief said "i18n route is optional". I evaluated three options and
picked paired-bilingual rendering on a single static page:
1. **Locale-switching via i18n provider** — rejected. Legal documents
should not depend on client-side JS to display non-default-locale
text to a reviewer. Apple US reviewers and German DPAs land at
the same URL.
2. **Two routes (`/privacy/de` + `/privacy/en`)** — rejected. Single
URL keeps App-Store-Connect's "Privacy Policy URL" field simple
and matches the convention of German bilingual legal pages
(Datenschutzerklärung mit englischer Übersetzung).
3. **Paired bilingual on one static page** — chosen. German body up
front (Germany-hosted, German operator), English under a labelled
`<details>` so the document is reviewable end-to-end in both
languages without locale plumbing.
Existing `messages/de.json` + `messages/en.json` were therefore left
untouched. The `auth.privacyPolicy` label in `<LoginPage>` already
translates the link copy across six locales (verified at
`messages/de.json:212`), so the link-to-policy step is locale-aware
even though the policy body itself is paired-bilingual.
### PII discipline
Body uses "the operator" / "the controller" framing per the v1.4.20
retroactive PII rule. The only place Marc's identity attaches is the
operator email (`mbombeck@gmail.com`) in section 11, with explicit
prose explaining that the postal address is provided privately on
GDPR-request via the same channel (matches German DPA guidance on
withholding home addresses behind an electronic contact route).
The "no `Marc[- ]?Andr` / no `Bombeck \w+`" assertion stays in the
test (line 250).
---
## App-Store-Connect submission
- **URL:** `https://healthlog.bombeck.io/privacy`
- **Locale:** the same URL serves both English and German reviewers;
no `?locale=` parameter is required.
- **Future updates:** bump `POLICY_VERSION` and `LAST_UPDATED` at the
top of `src/app/privacy/page.tsx` whenever the policy materially
changes. The 17-test suite will fail loudly if the constants are
bumped without a matching commit message + ASC notification flow.
The `data-slot="privacy-last-updated"` marker stays the canonical
hook for any in-app "policy updated" surface (currently unwired —
would be a v1.5 nice-to-have).
- **Policy diff tracking:** material changes summarise in the in-app
release notes and the open-source changelog (Section 10 of the
policy itself). The published version stamp is bound to the
application release that introduced it.
---
## Quality gates evidence
- `pnpm typecheck` — clean.
- `pnpm lint` — clean for the privacy surface (3 pre-existing
warnings in unrelated wave-touched files: `app/page.tsx`,
`insights/features.ts`, `api/dashboard/summary/route.ts`).
- `pnpm vitest run src/app/privacy` — 17 / 17 pass.
- `pnpm vitest run src/__tests__/proxy-privacy-public.test.ts` — 3 / 3
pass (PUBLIC_PATHS still includes `/privacy`).
- `pnpm vitest run src/__tests__/i18n-drift-guard.test.ts
src/lib/__tests__/i18n-locale-integrity.test.ts` — 57 / 57 pass
(no i18n keys added, no drift introduced).
---
## File touch list (only my surface)
- `src/app/privacy/page.tsx` — rewrite.
- `src/app/privacy/__tests__/page.test.tsx` — expanded.
- `.planning/phase-W-PRIVACY-v1440-report.md` — this report.
No other surfaces touched. The other staged modifications in the
working tree (`src/app/api/...`, `src/lib/insights/features.ts`,
`src/app/page.tsx`, `src/app/api/medications/intake/route.ts`,
`src/lib/ai/coach/snapshot.ts`, etc.) belong to other waves and were
not staged by W-PRIVACY.
+146
View File
@@ -0,0 +1,146 @@
# Phase W-RSC v1.4.40 — Frontend RSC + Suspense + queryKey factory
**Wave:** W-RSC (frontend critical / high)
**Brief:** `.planning/round-v1439-arch-qa-frontend.md`
**Branch:** `develop`
**Range:** `c9d5479b` → HEAD (`691443e2`)
## Shipped
### 1. queryKey factory enforcement
- `src/lib/query-keys.ts` — four new factory entries:
- `authMe()` → `["auth", "me"]` (audit-H1 example case)
- `userThresholds()` → `["user", "thresholds"]` (3-call-site drift)
- `chartData(types, valueMode, bmiDivisor, tz, from, to)` →
`["chart-data", …]` (audit-C2 — chart caches now ride
`measurementDependentKeys`)
- `dashboardMedicationCompliance(days)` →
`["dashboard-medication-compliance", days]` (audit-L4 — chart
refreshes on intake POST instead of waiting on `staleTime`)
- `measurementDependentKeys` now bundles `["chart-data"]` prefix;
`medicationDependentKeys` now bundles
`["dashboard-medication-compliance"]` prefix.
- Migrated call sites: `src/hooks/use-auth.ts`,
`src/app/insights/page.tsx`, `src/app/insights/medikamente/page.tsx`,
`src/app/insights/stimmung/page.tsx`,
`src/components/insights/insights-layout-shell.tsx`,
`src/components/charts/health-chart.tsx`,
`src/components/charts/medication-compliance-chart.tsx`,
`src/components/charts/mood-chart.tsx`.
### 2. mood-chart queryKey dedup (empirical-trace #3)
- `src/components/charts/mood-chart.tsx:316` was bare
`["mood-chart-data"]`; the dashboard at `app/page.tsx:291` already
ran `queryKeys.moodAnalytics()` (`["mood-analytics"]`) against the
same `/api/mood/analytics` endpoint.
- Re-keyed to `queryKeys.moodAnalytics()` so TanStack dedups the
request. Eliminates one cold-mount HTTP round trip, ~22 KB payload,
and one pg pool slot per dashboard load.
- Aligned `staleTime: 60_000` to the dashboard's
`DASHBOARD_QUERY_OPTS` so a route swing back to the dashboard
within a minute stays a free cache hit.
### 3. Per-tile Suspense boundaries (audit-H2 + brief C1)
- `src/app/page.tsx` — wrapped every chart-row cell in
`<Suspense fallback={<ChartSkeleton />}>` and every tile-strip
cell in `<Suspense fallback={null}>`.
- The dynamic-loaded charts (`HealthChartDynamic`, `MoodChart`,
`MedicationComplianceChart`) already paint a `<ChartSkeleton>`
during JS chunk resolution via `next/dynamic({ loading: … })`;
the Suspense layer lifts the same skeleton to a streaming-
compatible boundary so a future `useSuspenseQuery` migration
automatically buckets each chart's loading state to its own
cell without a further composition pass.
### 4. Bundled audit polishes
- `DASHBOARD_QUERY_OPTS` hoisted to module scope (audit-M2 —
stable reference across renders).
- `getHourForTimeZone` memoised via `useMemo` keyed on
`user?.timezone` (audit-H4 — keeps the `Intl.DateTimeFormat`
instantiation off the per-render hot path).
### 5. Tests
- `src/lib/__tests__/query-keys.test.ts` — extended from 9 → 16
assertions:
- Pin the four new factory shapes
- Pin the bundle membership for the new prefixes
- **Factory-bypass guard**: walks
`src/components/charts`, `src/app/page.tsx` and
`src/hooks/use-auth.ts` and fails if any `queryKey:` literal
array slipped past the factory. Acts as lint-style enforcement
in lieu of a custom ESLint rule.
- `src/app/__tests__/dashboard-suspense-boundaries.test.ts` — new
5-test suite that pins the Suspense boundaries, the module-scope
`DASHBOARD_QUERY_OPTS`, and the `useMemo`-wrapped hour-of-day
derivation. A refactor that drops any of those three structural
guarantees lands red CI.
## Decisions deferred
- **`app/page.tsx` RSC migration** (brief item 3). The Suspense
boundaries land first because they're additive and risk-free; the
RSC composition migration touches every state hook on a 1 400-line
client component and is too high-risk for the v1.4.40 envelope.
Recommendation: open a v1.4.41 phase that:
1. Carves out a `<DashboardComposition>` server-component shell
that fetches the slim-analytics + widgets server-side via
`headers()` + the shared session helper.
2. Streams the slim payload through `<Suspense>` to the existing
client tile-strip island.
3. Keeps thick analytics + per-chart `["chart-data", …]` queries
on the client.
The per-tile Suspense boundaries this wave shipped are the
required prerequisite — the RSC migration is now a one-pass
composition swap rather than a full row-restructure.
- **Custom ESLint rule for queryKey factory** (brief item 2). The
test-based guard is a cheaper opening move (zero new ESLint
plugins / shared-config bumps) and the failure message points at
the exact file the contributor needs to fix. A v1.4.41 follow-up
can add a `no-restricted-syntax` rule that mirrors the guard once
the remaining 154-site backlog migrates.
- **Long-tail factory migration** (audit-H1 — 154 bare sites). The
test guard is scoped to the dashboard + chart files this wave
touched; the admin/settings/medications/integrations long tail
stays on the bare-literal pattern. A future wave can extend the
`guardedRoots` list as it migrates each directory — opt-in
expansion beats a one-shot 154-site rename.
## Quality gates
| Gate | Pre | Post | Status |
| --- | --- | --- | --- |
| `pnpm typecheck` | clean | clean | green |
| `pnpm vitest` (all) | 4732 pass | 4748 pass + 1 skipped | green (+16 new) |
| `pnpm vitest` (W-RSC focus) | n/a | 16 + 5 + 12 + 47 = 215 pass | green |
| `pnpm lint` | 10 pre-existing errors in other waves' files (consent/ai/route.ts, privacy/page.tsx) | identical | unchanged |
| Playwright `dashboard.spec.ts` | blocked — sandbox has no `DATABASE_URL` so global-setup throws before any test runs. The CI environment has the DB and will gate normally. |
## Commits
1. **Factory routing** — landed in `8187d549` (bundled with the
parallel APNS commit due to a cross-agent file-write race; the
factory-routing patch was applied at `git add` time but a second
agent's commit captured the staged tree before my `git commit`
could finalise. Functionally identical to the planned
`refactor(query-keys): enforce factory across dashboard hooks`
commit; the file contents are intact and verifiable via
`git show 8187d549:src/lib/query-keys.ts`).
2. `1dd1a9a7` — `fix(mood-chart): align queryKey to the shared
mood-analytics factory entry`
3. `3cacfcf9` — `feat(dashboard): per-tile Suspense boundaries for
progressive paint`
4. `691443e2` — `test(query-keys): pin factory enforcement and dedup
behaviour`
## Observed pattern — cross-agent commit drift
The factory-routing commit (1) was the second time this marathon a
parallel agent's commit absorbed staged-but-not-yet-committed work
from a sibling agent's worktree. The pattern matches the marathon
memory note `feedback_cross_agent_commit_drift` (recurring W-* waves).
Mitigation suggestion for v1.4.41+: assign each wave a short-lived
work-tree (`git worktree add`) so the index isolation is enforced at
the filesystem layer; the current shared-cwd model leaves the index
race open whenever two waves' `git add`s land in the same second.
+129
View File
@@ -0,0 +1,129 @@
# Phase W-WMY-WIRE — v1.4.40 report
Wires the v1.4.39 W-WMY readers (`readWeekRollups`,
`readMonthRollups`, `readYearRollups`, `readBestGranularityRollups`)
into the read paths that benefit. Before this phase those exports
existed but had zero callers; the WEEK / MONTH / YEAR rollup tier
was paying full write amplification per measurement with no read-side
return. After this phase the long-window comparison signal that the
dashboard tile-strip and Health Score helper need is served from the
existing MONTH buckets instead of a live `measurements` walk or a
hardcoded null.
## Audit anchors
- `.planning/round-v1439-arch-qa-infra-db.md` — Critical Finding #1
(WEEK / MONTH / YEAR write-only).
- `.planning/round-v1439-arch-qa-ghosts.md` — confirms the four WMY
readers were dead exports through v1.4.39.
- `.planning/round-v1438-perf-analysis.md` §2 + §5 P6 — design intent
"no read path consumes WEEK / MONTH / YEAR buckets … the largest
single architectural gap".
## Commits (on `develop`)
1. `24568c80` `perf(summaries-slice): consume monthly rollup buckets
for long-window slice` — populates `DataSummary.avg30LastYear` per
type via `readBestGranularityRollups(userId, type, 395)` on both
the rollup happy path and the live fallback. Filters bucket rows to
the `[now-395d, now-365d)` slice and composes the count-weighted
mean linearly. Coverage-fallback returns `null` (existing UI
behaviour). New `slim_summaries.year_over_year_types` annotate
makes the wire-up visible in production wide-events.
2. `9009f1bb` `perf(health-score-fast-path): consume monthly rollup
buckets for slope90` — parallel long-window weight read via
`readBestGranularityRollups(userId, "WEIGHT", 365)` alongside the
canonical 37-day DAY-bucket read inside the WEIGHT-covered branch.
Score shape unchanged; `healthScore.weightLongWindow` annotate
carries `{ mean, granularity, buckets }` so operators can verify
the rollup tier serves production traffic. Live fallback branch
deliberately omits the long-window probe to avoid double-reading
on cold mounts.
3. `b1469c46` `test(rollup-read-wmy): parity and granularity-routing
across consumers` — cross-consumer routing parity (90 d → DAY, 365
d → MONTH, 1095 d → YEAR) plus a linear-composability parity test
pinning `count / mean` agreement between a single MONTH bucket and
the underlying DAY buckets that consolidate into it.
## Quality gates
- `pnpm test --run src/lib/analytics src/lib/measurements` — 406 / 406
pass (28 files).
- `pnpm typecheck` — clean for the files I touched. Pre-existing
errors in unrelated files (`src/lib/__tests__/glucose.test.ts`,
`.next/.../validator.ts`) are from other-agent edits committed
during the wave and are not introduced by this phase.
- `pnpm lint` — clean for the files I touched. Pre-existing error in
`src/app/page.tsx` (`react-hooks/preserve-manual-memoization`) is
from `3cacfcf9` (W-RSC) and is not introduced by this phase.
## File set
- `src/lib/analytics/summaries-slice.ts` — added
`computeAvg30LastYearForType` + `computeAvg30LastYearMap` helpers
and wired both `computeFromRollups` and `computeFromLiveAggregate`
to populate `avg30LastYear`. `computeLongWindowSummary` (the
v1.4.39 W-WMY export) remains the public helper for one-off
consumers; the wave's wire-in fans out via the new internal map
helper because the fan-out is per-type-with-data, not single-type.
- `src/lib/analytics/health-score-fast-path.ts` — added a parallel
365-day weight read on the WEIGHT-covered branch. Emits
`weightLongWindow` on the existing healthScore annotate.
- `src/lib/measurements/rollup-read-wmy.ts` — untouched. The existing
signatures + the `GRANULARITY_FLOORS` table already supported every
wire-in target.
- `src/lib/analytics/__tests__/summaries-slice.test.ts` — extended
rollup-fresh test mock-count from 0 → 3 to reflect the new
per-type WMY probe, plus four new tests in a `year-over-year
wiring (avg30LastYear)` describe block (populated, recent-bucket
miss, full coverage miss, no-types-with-data short-circuit).
- `src/lib/analytics/__tests__/health-score-fast-path.test.ts` —
extended the existing rollup-branch test with a long-window mock,
plus three new tests in a `long-window weight wiring` describe
block (MONTH-routing happy path, coverage-miss surfaces null,
live-branch never probes the WMY tier).
- `src/lib/measurements/__tests__/rollup-read-wmy.test.ts` — added a
`cross-consumer routing parity` describe block with the routing
pin (90 / 365 / 1095) + a linear-composability parity assertion.
## Method (recap)
Synced develop. Confirmed W-INSIGHTS (`45a83998`) had already touched
`src/app/api/insights/comprehensive/route.ts` so the file was skipped
per the instruction's gate clause. Read every audit anchor + the WMY
reader module end-to-end before any wiring.
Routing decisions:
- `summaries-slice`: the dashboard tile-strip's year-ago delta
callout consumed `avg30LastYear` and the comprehensive aggregator's
rollup branch had hardcoded the field to `null` because the
90-day windowed `$queryRaw` could not reach back a year. The
rollup-tier wiring is the cleanest single change that lights up
the existing UI control without touching the
`comprehensive-aggregator.ts` file W-INSIGHTS already owned.
- `health-score-fast-path`: there is no `slope90` consumer in the
helper today (the score reads a 30/37-day weight series). The
literal "switch slope90 from live SQL to MONTH buckets" target
doesn't exist in the file. Instead the wire-in adds an additive
long-window weight baseline (annotate-only) so the WMY readers
carry observable production traffic, and the helper's existing
shape stays stable for the v1.5 Coach drawer integration.
## Cross-agent commit drift
Multiple other-agent commits landed on `develop` during the phase
(`75607e4c`, `9df23c3c`, `2b4e2177`, `2bff80aa`, plus the test/route
drops). One commit-staging accident was caught and reset before push
(`git reset --soft HEAD~1` recovered the staged state; only the
intended files were re-staged for the corrected commit). The recovery
proves the cross-agent stage-discipline check in the wave brief is
necessary, not paranoid.
## Reply
WMY readers wired. `avg30LastYear` lights up on the slim summaries
slice via MONTH buckets, weight long-window mean lights up on the
Health Score annotate via MONTH buckets, and a parity test pins the
routing contract both consumers share. Three commits on develop.
+48
View File
@@ -1,5 +1,53 @@
# Changelog
## [1.4.40] — 2026-05-21 — Comprehensive Critical+High architecture closure and iOS PB30 enablement
v1.4.39.x stitched the rollup tier across mood, medication compliance, and cumulative metrics, and closed the dashboard read paths the rollup tier replaced. v1.4.40 is the architecture-closure release on top of that base: the Prisma pool starvation root cause documented in the v1.4.39 empirical trace is fixed at the source, the soft-delete invisibility contract is now consistent across every reader tier (the audit's Critical Finding #3), the per-tile Suspense boundaries that let dashboard chart tiles stream independently are in place, the queryKey factory has CI enforcement that catches bare-literal regressions, and the iOS PB30 backend prerequisites (Apple App-Site Association, AI consent receipts CRUD, time-sensitive APNs payload, public privacy page, notifications/status surface) are live so the iOS v0.5.x sprint can land its dependent screens without further backend churn.
Eleven touch-disjoint waves landed on `develop` before this release commit. The wave reports under `.planning/phase-W-*-v1440-report.md` document the per-wave decisions, file inventories, and test deltas; the multi-axis QA round under `.planning/round-v1440-QA-*-findings.md` covers the cross-cutting verdict that gated this tag.
### Added
- **`POST /api/consent/ai` + `GET /api/consent/ai/latest` + `DELETE /api/consent/ai`** — AI consent receipts for App-Store Guideline 5.1.2(i) and GDPR Art. 7 audit trail. Discriminated by `kind` over `ai_full / ai_insights_only / ai_coach` so each consent surface the iOS client collects independently ends up as its own row. Append-only: revoking a receipt writes a new row, never mutates history. 64 KB byte-bounded artefact cap (UTF-8 byte length, not character count) keeps the audit table from absorbing multi-megabyte rows from a misbehaving client.
- **`GET /.well-known/apple-app-site-association`** — universal-link AASA payload pinned to `S8WDX4W5KX.dev.healthlog.app`. Response is served as `application/json` without a charset parameter (Apple's `swcd` daemon is strict about the Content-Type shape) and the payload structure is regression-tested for app-ID parity against `applinks.details[].appIDs`, `webcredentials.apps`, and `appclips.apps` so a future split rotation cannot drift one bundle out of the trio.
- **`GET /privacy`** — public bilingual (German / English) privacy page covering the nine SB-3 disclosure requirements (data categories, third-party processors, sub-processors, retention, deletion rights, AI-assistance scope, marketing posture, contact). Paired-section layout (no JS-driven locale switching) so the static document remains comprehensible to a regulator regardless of the browser's `Accept-Language`.
- **`GET /api/notifications/status`** — operator-and-iOS-side surface for the APNs reachability ledger. Reports `aps_ready / aps_last_error / aps_last_delivery_at / device_token_count` so the iOS app can flag a stale-token failure mode without a separate diagnostic call.
- **CI knip gate** (`.github/workflows/knip.yml`) — fails any push to `main` carrying unused exports, unlisted dependencies, or orphaned binaries. The whitelist (`knip.json`) carries three files (`e2e/setup/test-helpers.ts`, `compliance-line-chart.tsx`, `src/lib/logging/index.ts`) deferred pending the "delete with their dedicated tests" follow-up; everything else is green.
- **`src/lib/rollups/` umbrella** — every rollup helper now lives under one canonical import root. The previous in-tree split between `src/lib/measurements/rollups*`, `src/lib/mood/mood-rollups*`, and `src/lib/medications/medication-compliance-rollups*` is collapsed into `@/lib/rollups/{measurement,mood,medication-compliance,read-wmy,read-cumulative}.ts`. Importers updated in lockstep; zero orphan re-exports.
### Changed
- **Per-tile Suspense boundaries on the dashboard.** Each chart tile now mounts inside its own `<Suspense>` so a slow tile no longer blocks the others on first paint. The parent gate is the slim / thick analytics merge (already split per v1.4.39.2); the per-tile Suspense layer is the structural foundation the v1.4.41 React Server Components migration will graft onto. `mood-chart` queryKey dedup eliminates one round-trip on cold mount.
- **queryKey factory enforcement** (`src/lib/__tests__/query-keys.test.ts`). A walker test fails CI if any guarded file declares a bare-literal `queryKey: [...]`. Guarded roots are `src/components/charts/`, `src/components/comparison/`, `src/app/page.tsx`, and `src/hooks/use-auth.ts`. Cheaper than a custom ESLint rule and points the failure message at the exact `file:line` a contributor needs to fix; opt-in expansion as future waves migrate the remaining sites away from bare literals.
### Fixed
- **Prisma pool starvation root cause** (empirical-trace finding #1). The 15-way `fetchMeasurementSeriesChunked` fan-out in `/api/analytics` thick used to monopolise ≥ 8 of the default-10 `pg.Pool` connections during a power-user cold mount, blocking every Wave-B and Wave-C chart-tile fetch behind it. The fan-out is now wrapped in `p-limit(4)` so analytics holds at most 4 pool slots, and the `pg.Pool` `max` is raised from the library default 10 → 20 (overridable via `DATABASE_POOL_MAX`) so a second concurrent power-user retains ≥ 8 free slots after both branches hit their `p-limit(4)` cap. The cap is a per-request instance, not module-level, so a stale limit cannot leak in-flight state across HTTP boundaries.
- **Soft-delete invisibility full-wire** (audit Critical Finding #3). Eleven reader-tier helpers across `src/lib/measurements/rollups.ts`, `src/lib/measurements/rollup-coverage.ts`, `src/lib/analytics/{summaries-slice,correlations-fast-path,bp-in-target-fast-path,health-score-fast-path}.ts`, `src/lib/insights/comprehensive-aggregator.ts`, `src/app/api/dashboard/summary/route.ts`, `src/app/api/measurements/route.ts`, `src/app/api/measurements/series/route.ts`, and `src/lib/ai/coach/snapshot.ts` now filter `deletedAt: null` (or the SQL equivalent `m."deleted_at" IS NULL`) at every aggregate, every cursor walk, every `DISTINCT ON` latest probe, and every rollup-rebuild SQL. Three integration-test contracts in `tests/integration/measurement-soft-delete.test.ts` pin the invariant against a Postgres testcontainer.
- **Six remaining insights `measurement.findMany` sites** that the W-INSIGHTS wave's mood-rollup swap left unfiltered (`/api/insights/{targets,cards,generate}` plus `src/lib/insights/{features,glp1-plateau,pulse-status}.ts`). All six now filter `deletedAt: null` so the iOS-adapter card stream, the AI prompt feature aggregator, the GLP-1 plateau detector window, and the per-type tile-strip averages stop counting tombstoned readings once iOS sync starts emitting deletions.
- **Compliance-rollup hook gap on bulk-projection paths.** Both `/api/medications/intake?scope=today` and `/api/dashboard/summary` mint fresh `(medicationId, scheduledFor)` rows in PENDING state when a daily schedule is projected on first read. Without a recompute hook the rollup for the affected `(user, medication, day)` tuples stayed at its previous (pre-projection) `scheduled` count, which inflated the apparent compliance % until the user logged against the new row. Both call sites now fire one recompute per distinct `(medicationId, dayKey)` tuple, deduplicated through a `Set` so the cost stays bounded. The recompute call is wrapped in `Promise.allSettled` so any future change that lets the helper throw still leaves the user request 200-OK.
- **`/api/dashboard/summary` nested-ternary regression** in the heroNumber branch flattened to an `if / else if / else` chain so the linter, the type-narrower, and a human reader all parse the same way.
- **Lint regression** in `src/lib/rollups/` (post-umbrella-move) — a stray `any` import path and one un-narrowed `unknown` resolved by the typecheck-led restructure.
- **`dashboard-suspense-boundaries.test.ts` regex pin** updated to the post-W-INFRA shape. The test pinned `useMemo(..., [user?.timezone])` but the production code lifts `user?.timezone` to a `userTimezone` local one line above the `useMemo` so the dependency array stays a stable reference across renders.
- **Consent artefact 64 KB cap** enforced via `Buffer.byteLength(value, "utf8")` (not the prior `z.string().max()` which counts UTF-16 code units). A UTF-8 artefact full of multi-byte code points would have slipped past the 64 KB row budget; the audit-table guarantee is byte-bounded, not code-unit-bounded.
### Performance
Expected on Marc-sized accounts; numbers anchored on the v1.4.40 empirical trace and the wave-level instrumentation. Live perf-verify rides the post-deploy window.
- **Wave-C chart-tile first-paint: +7.3 s → +1.6 s.** Bounded analytics fan-out (p-limit 4 + pool max 20) lets the 6× `/api/measurements?source=rollup` Wave-C burst release incrementally as analytics rotates lanes, instead of gating the entire burst behind the thick analytics drain.
- **6 insights routes cold-mount.** The mood-rollup swap on `/api/insights/{features,targets,cards}` moves mood aggregation off the live `MoodEntry.findMany` walk onto the v1.4.39 mood-rollup tier; the cold-mount budget for the affected routes drops onto the same flat-200 ms band the v1.4.39 `/api/mood/analytics` numbers land in.
- **`avg30LastYear` now populated.** The 425-day `since` cap on the `/api/analytics` live-fallback per-type loop (v1.4.39) lets the year-ago baseline tile resolve from raw data without forcing a 347 k-row scan; the v1.4.40 pool cap keeps that fallback off the hot path.
- **`slope90` via MONTH-tier reader.** `readBestGranularityRollups` auto-routes the 90-day slope window onto the MONTH bucket where coverage allows, eliminating a per-cold-mount aggregate on multi-year tenants.
### Operator notes
- **Migration 0074** adds the `consent_receipts` table (id, userId, kind, artefact, signedAt, revokedAt, createdAt) and the matching index over `(userId, kind, revokedAt, signedAt DESC)`. Additive; no destructive column drops; safe to run forward on a live database.
- **AP-2 .p8 key install gates SB-5.** The APNs `time-sensitive + priority 10` payload landed in the worker (`MEDICATION_REMINDER` only — the parameterised test pins all six other event-types do not bypass Focus) but real delivery requires the production `.p8` private key to be installed in the Coolify secret store. Until that key lands, `aps_last_error` will surface as `auth-failed` on `/api/notifications/status` — that is the expected pre-key state, not a regression of this release.
- **No breaking API contract change for iOS v0.5.4.** Every existing route shape is byte-compatible; the new routes (`/api/consent/ai*`, `/.well-known/apple-app-site-association`, `/api/notifications/status`, `/privacy`) are additive surfaces.
- **No env-var change required for upgrade.** `DATABASE_POOL_MAX` is optional (defaults to 20).
- `pnpm test --run` green at 4726 passing / 1 skipped (4727 total); `pnpm typecheck`, `pnpm lint`, `pnpm knip --include files,dependencies,binaries,unlisted` all green; the knip CI gate now fails any push to `main` carrying unused exports.
## [1.4.39.4] — 2026-05-21 — Dashboard symmetry for daily-schedule intake projection
v1.4.39.3 fixed `/api/medications/intake?scope=today` to project active schedules through the new `expandTodayIntakes` helper and idempotently backfill missing rows for daily meds (`daysOfWeek: null`). `/api/dashboard/summary` reads the same today-window for its compliance tile but had no projection step, so the iOS Dashboard tile fell to "Heute nichts geplant" even when the intake route surfaced the same meds correctly.
+73
View File
@@ -0,0 +1,73 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"entry": [
"src/app/**/page.tsx",
"src/app/**/layout.tsx",
"src/app/**/route.ts",
"src/app/**/loading.tsx",
"src/app/**/not-found.tsx",
"src/app/**/error.tsx",
"src/app/**/global-error.tsx",
"src/app/**/template.tsx",
"src/app/**/opengraph-image.tsx",
"src/app/**/twitter-image.tsx",
"src/app/**/icon.tsx",
"src/app/**/apple-icon.tsx",
"src/app/**/manifest.ts",
"src/app/**/robots.ts",
"src/app/**/sitemap.ts",
"src/app/**/default.tsx",
"src/middleware.ts",
"src/instrumentation.ts",
"src/lib/jobs/**/*.ts",
"src/lib/health/**/*.ts",
"scripts/**/*.ts",
"scripts/**/*.mjs",
"e2e/**/*.spec.ts",
"e2e/**/*.test.ts",
"tests/**/*.test.ts",
"tests/**/setup.ts",
"vitest.config.ts",
"vitest.config.integration.ts",
"playwright.config.ts",
"next.config.ts",
"eslint.config.*",
"knip.json"
],
"project": [
"src/**/*.{ts,tsx}",
"scripts/**/*.{ts,mjs}",
"tests/**/*.ts",
"e2e/**/*.ts"
],
"ignore": [
"src/generated/**",
"prisma/**",
"public/**",
".next/**",
"**/__mocks__/**",
"e2e/setup/test-helpers.ts",
"src/components/charts/compliance-line-chart.tsx",
"src/lib/logging/index.ts"
],
"ignoreDependencies": [
"@types/node",
"@types/react",
"@types/react-dom",
"@hookform/resolvers",
"react-hook-form",
"@prisma/client",
"next-themes",
"@vitejs/plugin-react",
"shadcn",
"tailwindcss",
"testcontainers",
"tw-animate-css"
],
"ignoreBinaries": [
"docker",
"docker-compose",
"pg_isready",
"tsx"
]
}
-5
View File
@@ -33,11 +33,6 @@
"showPassword": "Passwort anzeigen",
"hidePassword": "Passwort verbergen"
},
"format": {
"dateShort": "{day}.{month}.{year}",
"timeShort": "{hour}:{minute}",
"dateTime": "{day}.{month}.{year} {hour}:{minute}"
},
"doctorReport": {
"title": "Gesundheitsbericht",
"subtitle": "HealthLog — Persönlicher Gesundheitsbericht",
-5
View File
@@ -33,11 +33,6 @@
"showPassword": "Show password",
"hidePassword": "Hide password"
},
"format": {
"dateShort": "{month}/{day}/{year}",
"timeShort": "{hour}:{minute}",
"dateTime": "{month}/{day}/{year} {hour}:{minute}"
},
"doctorReport": {
"title": "Health Report",
"subtitle": "HealthLog — Personal Health Report",
-5
View File
@@ -33,11 +33,6 @@
"showPassword": "Mostrar contraseña",
"hidePassword": "Ocultar contraseña"
},
"format": {
"dateShort": "{day}/{month}/{year}",
"timeShort": "{hour}:{minute}",
"dateTime": "{day}/{month}/{year} {hour}:{minute}"
},
"doctorReport": {
"title": "Informe de salud",
"subtitle": "HealthLog — Informe personal de salud",
-5
View File
@@ -33,11 +33,6 @@
"showPassword": "Afficher le mot de passe",
"hidePassword": "Masquer le mot de passe"
},
"format": {
"dateShort": "{day}/{month}/{year}",
"timeShort": "{hour}:{minute}",
"dateTime": "{day}/{month}/{year} {hour}:{minute}"
},
"doctorReport": {
"title": "Rapport de santé",
"subtitle": "HealthLog — Rapport personnel de santé",
-5
View File
@@ -33,11 +33,6 @@
"showPassword": "Mostra password",
"hidePassword": "Nascondi password"
},
"format": {
"dateShort": "{day}/{month}/{year}",
"timeShort": "{hour}:{minute}",
"dateTime": "{day}/{month}/{year} {hour}:{minute}"
},
"doctorReport": {
"title": "Rapporto sanitario",
"subtitle": "HealthLog — Rapporto personale di salute",
-5
View File
@@ -33,11 +33,6 @@
"showPassword": "Pokaż hasło",
"hidePassword": "Ukryj hasło"
},
"format": {
"dateShort": "{day}.{month}.{year}",
"timeShort": "{hour}:{minute}",
"dateTime": "{day}.{month}.{year} {hour}:{minute}"
},
"doctorReport": {
"title": "Raport zdrowotny",
"subtitle": "HealthLog — Osobisty raport zdrowotny",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.39.4",
"version": "1.4.40",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -66,6 +66,7 @@
"mmdb-lib": "^3.0.2",
"next": "16.2.6",
"next-themes": "^0.4.6",
"p-limit": "^7.3.0",
"pg-boss": "^12.18.2",
"prisma": "^7.8.0",
"radix-ui": "^1.4.3",
@@ -97,6 +98,7 @@
"@vitest/coverage-v8": "^4.1.5",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"knip": "^6.14.1",
"pdf-parse": "^2.4.5",
"pg": "^8.20.0",
"prettier": "^3.8.3",
+549
View File
@@ -65,6 +65,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
p-limit:
specifier: ^7.3.0
version: 7.3.0
pg-boss:
specifier: ^12.18.2
version: 12.18.2
@@ -153,6 +156,9 @@ importers:
eslint-config-next:
specifier: 16.2.6
version: 16.2.6(@typescript-eslint/parser@8.59.2(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.3(jiti@2.7.0))(typescript@6.0.3)
knip:
specifier: ^6.14.1
version: 6.14.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
pdf-parse:
specifier: ^2.4.5
version: 2.4.5
@@ -494,6 +500,9 @@ packages:
'@electric-sql/pglite@0.4.1':
resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==}
'@emnapi/core@1.10.0':
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
'@emnapi/core@1.8.1':
resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==}
@@ -506,6 +515,9 @@ packages:
'@emnapi/wasi-threads@1.1.0':
resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==}
'@emnapi/wasi-threads@1.2.1':
resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==}
'@esbuild/aix-ppc64@0.27.7':
resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
engines: {node: '>=18'}
@@ -1071,6 +1083,12 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@next/bundle-analyzer@16.2.6':
resolution: {integrity: sha512-amPkVtHCTJAdBwyhhl5+qztHk24O4JlASgrWqh15AmnYi74apfZR46NGC0u4pM6BMAU1mYld4WdzD3cRBP3dOA==}
@@ -1266,6 +1284,244 @@ packages:
'@open-draft/until@2.1.0':
resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==}
'@oxc-parser/binding-android-arm-eabi@0.130.0':
resolution: {integrity: sha512-h/xYU8/7ADWzVSf5I+YalLpj33LOy9CI/zgbJNIZ5eunRBG+Czqa3lZsvuPHHf3rOt6z1c5+UzoxjbAzAvhwVw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxc-parser/binding-android-arm64@0.130.0':
resolution: {integrity: sha512-oFWFJrsGv9siFM4HjMqKNB7IuIZD/SMmZdCXl8xyx7lDplGvPKyewpOo272rSWgMXe2Wx7bWI0Yj+gkHv4qbeg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxc-parser/binding-darwin-arm64@0.130.0':
resolution: {integrity: sha512-sGUzupdTplK9jQg7eJZ878HfEgQjJNBc6dAYVWJ9W5aU+J8rLfRJhTVsKThiu1pNwm6Y1qKCcbC6WhNWSXR3Ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxc-parser/binding-darwin-x64@0.130.0':
resolution: {integrity: sha512-PsB4cdCISbC00Uy8eiD8bc2AkGWjZqrSrJnkBFuG2ptrrf6mZ2F5gLFSjOAVMMgZPg8B1D7OydJwLWSfyI2Plg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxc-parser/binding-freebsd-x64@0.130.0':
resolution: {integrity: sha512-DgABp3l38hS77JbXCV4qk1+n6DPym5u8zzwuweokezm2tX194nDSJDENbDRECxVsiNbprKATLbk+Z5wlHT0OHw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxc-parser/binding-linux-arm-gnueabihf@0.130.0':
resolution: {integrity: sha512-4Kn3CTEmwFrzhTSC/JuUW16qovmaMdX7jeSKbL8w0pLtLww7To1a2XJi9Z5uD8QWUkfUHhqfV+VD6dVzBnWzoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm-musleabihf@0.130.0':
resolution: {integrity: sha512-D35KZM3F4rRu1uAFKyBlg3Gaf/ybCjyaPR1hfgvk5ex8NtcTmRgc0JgSighEyNg96TPrFhemFba68SZuxaha8w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxc-parser/binding-linux-arm64-gnu@0.130.0':
resolution: {integrity: sha512-Q9o7oVlo955KHwS8l1u0bCzIx+JsZUA3XToLXC+MsMhye/9LeBQbt84nh120cl2XLy+TEzvugYDiHShg5yaX6Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-arm64-musl@0.130.0':
resolution: {integrity: sha512-EiJ/gC0ljbcwVpycC8YWw6ggMbtsPX8XMOt0mPx0aqWeMsNR+L9m05Flbvd5T+GlivG+GkSWQL7tM9SRFpM/dw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-ppc64-gnu@0.130.0':
resolution: {integrity: sha512-b+h/lsLLurp756dMGizNs5uPaJfyEdWrTcV5t8M609jWm1DEHB1StpRXCkyvwtkJx3m+qL5BNQ0dEKan/4yGFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-gnu@0.130.0':
resolution: {integrity: sha512-O19Cil83XAyjEFfo8WhkMwY58ALqZ7ckjGL+25mjMIuF84urWBeANH0FC8B8BsSSygWU3/1aY3ADdDbp+wlBnw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-riscv64-musl@0.130.0':
resolution: {integrity: sha512-BgXRVC0+83n3YzCscLQjj6nbyeBIVeZYPTI4fFMAE4WNm2+4RXhWp03IVizL7esIz36kgmT48aebk1iM+cs8sw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-linux-s390x-gnu@0.130.0':
resolution: {integrity: sha512-6tJz0xvnGhsokE7N1WlUSBXibpYmT9xSJFS1Ce41Km/+8gQvdlW8MLhRv8PD0L7ix8vRG0FDDepp3jdOFzdVdw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-gnu@0.130.0':
resolution: {integrity: sha512-9aCWj83dp3heTQGmGnZGdIWgxjZrr/7VQ0TGFHH5PKByxJKF2Hcr4qvaSUHhhGEa3MSsDjTL1YDP8RAgdL5/Cg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-parser/binding-linux-x64-musl@0.130.0':
resolution: {integrity: sha512-afXt87aZBqrUVli8TB/I8H1G50RDWcwirjWtXGXYqJ2ZqWEiErH7V72j3LUSDZaivmtu2OLX0KQ/mbhP81mr7A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-parser/binding-openharmony-arm64@0.130.0':
resolution: {integrity: sha512-I0NCrZV/YZuCGWgqwNN/GO/iXlLF2z+Wgc7u+Aa9N4P51oYeIa0XT+zVBUne4csO9GqxskXgI4g8JzzWGRpfOw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxc-parser/binding-wasm32-wasi@0.130.0':
resolution: {integrity: sha512-sJgQkGaBX0WJvPUDfwciex6IcTk5O5NLQ1bhEb6f3nBruh1GshKMRSMt2bxZlYrgBzjyBbJzsnO+InPG0bg+fA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@oxc-parser/binding-win32-arm64-msvc@0.130.0':
resolution: {integrity: sha512-bjcma99sQrNh6RY4mPO9yTkfxql6TDFoN3HWdK31RCKXwNhcDgJXW/l8PUtzKNiQ+9vpKJfJtQq+LklBuxSOBA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxc-parser/binding-win32-ia32-msvc@0.130.0':
resolution: {integrity: sha512-hRYbv6HhpSTzT4xTiIkadLI7upLQxuOdLPR/9nL1fTjwhgutBTPXrwaAPb/jTFVx6/8C7Jb5HcUKhmNwloTbFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxc-parser/binding-win32-x64-msvc@0.130.0':
resolution: {integrity: sha512-RBpA9TsRucJq6HNVNCFF1iKg+QeTkLdZf7hi4xaOGCPvMZWvDHjQgSOEZMUpuW4JNciHbxNhLEYmz5CVygjVGQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@oxc-project/types@0.130.0':
resolution: {integrity: sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==}
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
resolution: {integrity: sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==}
cpu: [arm]
os: [android]
'@oxc-resolver/binding-android-arm64@11.19.1':
resolution: {integrity: sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==}
cpu: [arm64]
os: [android]
'@oxc-resolver/binding-darwin-arm64@11.19.1':
resolution: {integrity: sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==}
cpu: [arm64]
os: [darwin]
'@oxc-resolver/binding-darwin-x64@11.19.1':
resolution: {integrity: sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==}
cpu: [x64]
os: [darwin]
'@oxc-resolver/binding-freebsd-x64@11.19.1':
resolution: {integrity: sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==}
cpu: [x64]
os: [freebsd]
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
resolution: {integrity: sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==}
cpu: [arm]
os: [linux]
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
resolution: {integrity: sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==}
cpu: [arm]
os: [linux]
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
resolution: {integrity: sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
resolution: {integrity: sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
resolution: {integrity: sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
resolution: {integrity: sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
resolution: {integrity: sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
resolution: {integrity: sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
resolution: {integrity: sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
resolution: {integrity: sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
resolution: {integrity: sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==}
cpu: [arm64]
os: [openharmony]
'@oxc-resolver/binding-wasm32-wasi@11.19.1':
resolution: {integrity: sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==}
engines: {node: '>=14.0.0'}
cpu: [wasm32]
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
resolution: {integrity: sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==}
cpu: [arm64]
os: [win32]
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
resolution: {integrity: sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==}
cpu: [ia32]
os: [win32]
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
resolution: {integrity: sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==}
cpu: [x64]
os: [win32]
'@parse/node-apn@8.1.0':
resolution: {integrity: sha512-LowcdkKPDikbbzIr3zwEdoFW5wfEbbTzpYQeen3S8kKLePG0AQK586Li+OLC7bonyLmlk//Yk3smHZOLSV6TZA==}
engines: {node: 20 || 22 || 24}
@@ -3752,6 +4008,9 @@ packages:
fastq@1.20.1:
resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==}
fd-package-json@2.0.0:
resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -3803,6 +4062,11 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
formatly@0.3.0:
resolution: {integrity: sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==}
engines: {node: '>=18.3.0'}
hasBin: true
formdata-polyfill@4.0.10:
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
engines: {node: '>=12.20.0'}
@@ -4367,6 +4631,11 @@ packages:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
knip@6.14.1:
resolution: {integrity: sha512-SN3Ly0ixzj5CQkY/rc4OPHpWrCC0XRIIjgdP76G9Cni5k72ur5jBYOyvJuF5oPTM14v8eHcMUgPbElHa+lnR0g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
language-subtag-registry@0.3.23:
resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==}
@@ -4804,10 +5073,21 @@ packages:
resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==}
engines: {node: '>= 0.4'}
oxc-parser@0.130.0:
resolution: {integrity: sha512-X0PJ+NmOok8qP3vK9uaW431ngkdM9UPEK7KG466urtIL2+EYTEgbZK2yqe2MWKJKBjRlFweP/pJPx0x9muMEVw==}
engines: {node: ^20.19.0 || >=22.12.0}
oxc-resolver@11.19.1:
resolution: {integrity: sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==}
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
p-limit@7.3.0:
resolution: {integrity: sha512-7cIXg/Z0M5WZRblrsOla88S4wAK+zOQQWeBYfV3qJuJXMr+LnbYjaadrFaS0JILfEDPVqHyKnZ1Z/1d6J9VVUw==}
engines: {node: '>=20'}
p-locate@5.0.0:
resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==}
engines: {node: '>=10'}
@@ -5452,6 +5732,10 @@ packages:
sisteransi@1.0.5:
resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
smol-toml@1.6.1:
resolution: {integrity: sha512-dWUG8F5sIIARXih1DTaQAX4SsiTXhInKf1buxdY9DIg4ZYPZK5nGM1VRIYmEbDbsHt7USo99xSLFu5Q1IqTmsg==}
engines: {node: '>= 18'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
@@ -5587,6 +5871,10 @@ packages:
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
engines: {node: '>=8'}
strip-json-comments@5.0.3:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
strnum@2.3.0:
resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==}
@@ -5771,6 +6059,10 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
unbash@3.0.0:
resolution: {integrity: sha512-FeFPZ/WFT0mbRCuydiZzpPFlrYN8ZUpphQKoq4EeElVIYjYyGzPMxQR/simUwCOJIyVhpFk4RbtyO7RuMpMnHA==}
engines: {node: '>=14'}
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -5952,6 +6244,10 @@ packages:
jsdom:
optional: true
walk-up-path@4.0.0:
resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==}
engines: {node: 20 || >=22}
web-push@3.6.7:
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
engines: {node: '>= 16'}
@@ -6048,6 +6344,11 @@ packages:
engines: {node: '>= 14.6'}
hasBin: true
yaml@2.9.0:
resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
engines: {node: '>= 14.6'}
hasBin: true
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
@@ -6060,6 +6361,10 @@ packages:
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
engines: {node: '>=10'}
yocto-queue@1.2.2:
resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==}
engines: {node: '>=12.20'}
yocto-spinner@1.2.0:
resolution: {integrity: sha512-Yw0hUB6UA3o4YUgKy3oSe9a4cxoaZ9sBfYDw+JSxo6Id0KoJGoxzPA24qqUXYKBWABs/zDSGTz9kww7t3F0XGw==}
engines: {node: '>=18.19'}
@@ -6606,6 +6911,12 @@ snapshots:
'@electric-sql/pglite@0.4.1': {}
'@emnapi/core@1.10.0':
dependencies:
'@emnapi/wasi-threads': 1.2.1
tslib: 2.8.1
optional: true
'@emnapi/core@1.8.1':
dependencies:
'@emnapi/wasi-threads': 1.1.0
@@ -6627,6 +6938,11 @@ snapshots:
tslib: 2.8.1
optional: true
'@emnapi/wasi-threads@1.2.1':
dependencies:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.27.7':
optional: true
@@ -7058,6 +7374,13 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.1
optional: true
'@next/bundle-analyzer@16.2.6':
dependencies:
webpack-bundle-analyzer: 4.10.1
@@ -7191,6 +7514,137 @@ snapshots:
'@open-draft/until@2.1.0': {}
'@oxc-parser/binding-android-arm-eabi@0.130.0':
optional: true
'@oxc-parser/binding-android-arm64@0.130.0':
optional: true
'@oxc-parser/binding-darwin-arm64@0.130.0':
optional: true
'@oxc-parser/binding-darwin-x64@0.130.0':
optional: true
'@oxc-parser/binding-freebsd-x64@0.130.0':
optional: true
'@oxc-parser/binding-linux-arm-gnueabihf@0.130.0':
optional: true
'@oxc-parser/binding-linux-arm-musleabihf@0.130.0':
optional: true
'@oxc-parser/binding-linux-arm64-gnu@0.130.0':
optional: true
'@oxc-parser/binding-linux-arm64-musl@0.130.0':
optional: true
'@oxc-parser/binding-linux-ppc64-gnu@0.130.0':
optional: true
'@oxc-parser/binding-linux-riscv64-gnu@0.130.0':
optional: true
'@oxc-parser/binding-linux-riscv64-musl@0.130.0':
optional: true
'@oxc-parser/binding-linux-s390x-gnu@0.130.0':
optional: true
'@oxc-parser/binding-linux-x64-gnu@0.130.0':
optional: true
'@oxc-parser/binding-linux-x64-musl@0.130.0':
optional: true
'@oxc-parser/binding-openharmony-arm64@0.130.0':
optional: true
'@oxc-parser/binding-wasm32-wasi@0.130.0':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@oxc-parser/binding-win32-arm64-msvc@0.130.0':
optional: true
'@oxc-parser/binding-win32-ia32-msvc@0.130.0':
optional: true
'@oxc-parser/binding-win32-x64-msvc@0.130.0':
optional: true
'@oxc-project/types@0.130.0': {}
'@oxc-resolver/binding-android-arm-eabi@11.19.1':
optional: true
'@oxc-resolver/binding-android-arm64@11.19.1':
optional: true
'@oxc-resolver/binding-darwin-arm64@11.19.1':
optional: true
'@oxc-resolver/binding-darwin-x64@11.19.1':
optional: true
'@oxc-resolver/binding-freebsd-x64@11.19.1':
optional: true
'@oxc-resolver/binding-linux-arm-gnueabihf@11.19.1':
optional: true
'@oxc-resolver/binding-linux-arm-musleabihf@11.19.1':
optional: true
'@oxc-resolver/binding-linux-arm64-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-arm64-musl@11.19.1':
optional: true
'@oxc-resolver/binding-linux-ppc64-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-riscv64-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-riscv64-musl@11.19.1':
optional: true
'@oxc-resolver/binding-linux-s390x-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-x64-gnu@11.19.1':
optional: true
'@oxc-resolver/binding-linux-x64-musl@11.19.1':
optional: true
'@oxc-resolver/binding-openharmony-arm64@11.19.1':
optional: true
'@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
optional: true
'@oxc-resolver/binding-win32-arm64-msvc@11.19.1':
optional: true
'@oxc-resolver/binding-win32-ia32-msvc@11.19.1':
optional: true
'@oxc-resolver/binding-win32-x64-msvc@11.19.1':
optional: true
'@parse/node-apn@8.1.0':
dependencies:
debug: 4.4.3
@@ -10003,6 +10457,10 @@ snapshots:
dependencies:
reusify: 1.1.0
fd-package-json@2.0.0:
dependencies:
walk-up-path: 4.0.0
fdir@6.5.0(picomatch@4.0.4):
optionalDependencies:
picomatch: 4.0.4
@@ -10058,6 +10516,10 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
formatly@0.3.0:
dependencies:
fd-package-json: 2.0.0
formdata-polyfill@4.0.10:
dependencies:
fetch-blob: 3.2.0
@@ -10577,6 +11039,26 @@ snapshots:
kleur@4.1.5: {}
knip@6.14.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
formatly: 0.3.0
get-tsconfig: 4.14.0
jiti: 2.7.0
minimist: 1.2.8
oxc-parser: 0.130.0
oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
picomatch: 4.0.4
smol-toml: 1.6.1
strip-json-comments: 5.0.3
tinyglobby: 0.2.16
unbash: 3.0.0
yaml: 2.9.0
zod: 4.4.3
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
language-subtag-registry@0.3.23: {}
language-tags@1.0.9:
@@ -10987,10 +11469,65 @@ snapshots:
object-keys: 1.1.1
safe-push-apply: 1.0.0
oxc-parser@0.130.0:
dependencies:
'@oxc-project/types': 0.130.0
optionalDependencies:
'@oxc-parser/binding-android-arm-eabi': 0.130.0
'@oxc-parser/binding-android-arm64': 0.130.0
'@oxc-parser/binding-darwin-arm64': 0.130.0
'@oxc-parser/binding-darwin-x64': 0.130.0
'@oxc-parser/binding-freebsd-x64': 0.130.0
'@oxc-parser/binding-linux-arm-gnueabihf': 0.130.0
'@oxc-parser/binding-linux-arm-musleabihf': 0.130.0
'@oxc-parser/binding-linux-arm64-gnu': 0.130.0
'@oxc-parser/binding-linux-arm64-musl': 0.130.0
'@oxc-parser/binding-linux-ppc64-gnu': 0.130.0
'@oxc-parser/binding-linux-riscv64-gnu': 0.130.0
'@oxc-parser/binding-linux-riscv64-musl': 0.130.0
'@oxc-parser/binding-linux-s390x-gnu': 0.130.0
'@oxc-parser/binding-linux-x64-gnu': 0.130.0
'@oxc-parser/binding-linux-x64-musl': 0.130.0
'@oxc-parser/binding-openharmony-arm64': 0.130.0
'@oxc-parser/binding-wasm32-wasi': 0.130.0
'@oxc-parser/binding-win32-arm64-msvc': 0.130.0
'@oxc-parser/binding-win32-ia32-msvc': 0.130.0
'@oxc-parser/binding-win32-x64-msvc': 0.130.0
oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0):
optionalDependencies:
'@oxc-resolver/binding-android-arm-eabi': 11.19.1
'@oxc-resolver/binding-android-arm64': 11.19.1
'@oxc-resolver/binding-darwin-arm64': 11.19.1
'@oxc-resolver/binding-darwin-x64': 11.19.1
'@oxc-resolver/binding-freebsd-x64': 11.19.1
'@oxc-resolver/binding-linux-arm-gnueabihf': 11.19.1
'@oxc-resolver/binding-linux-arm-musleabihf': 11.19.1
'@oxc-resolver/binding-linux-arm64-gnu': 11.19.1
'@oxc-resolver/binding-linux-arm64-musl': 11.19.1
'@oxc-resolver/binding-linux-ppc64-gnu': 11.19.1
'@oxc-resolver/binding-linux-riscv64-gnu': 11.19.1
'@oxc-resolver/binding-linux-riscv64-musl': 11.19.1
'@oxc-resolver/binding-linux-s390x-gnu': 11.19.1
'@oxc-resolver/binding-linux-x64-gnu': 11.19.1
'@oxc-resolver/binding-linux-x64-musl': 11.19.1
'@oxc-resolver/binding-openharmony-arm64': 11.19.1
'@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
'@oxc-resolver/binding-win32-arm64-msvc': 11.19.1
'@oxc-resolver/binding-win32-ia32-msvc': 11.19.1
'@oxc-resolver/binding-win32-x64-msvc': 11.19.1
transitivePeerDependencies:
- '@emnapi/core'
- '@emnapi/runtime'
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0
p-limit@7.3.0:
dependencies:
yocto-queue: 1.2.2
p-locate@5.0.0:
dependencies:
p-limit: 3.1.0
@@ -11767,6 +12304,8 @@ snapshots:
sisteransi@1.0.5: {}
smol-toml@1.6.1: {}
sonner@2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
dependencies:
react: 19.2.5
@@ -11924,6 +12463,8 @@ snapshots:
strip-json-comments@3.1.1: {}
strip-json-comments@5.0.3: {}
strnum@2.3.0: {}
styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5):
@@ -12156,6 +12697,8 @@ snapshots:
typescript@6.0.3: {}
unbash@3.0.0: {}
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
@@ -12313,6 +12856,8 @@ snapshots:
transitivePeerDependencies:
- msw
walk-up-path@4.0.0: {}
web-push@3.6.7:
dependencies:
asn1.js: 5.4.1
@@ -12431,6 +12976,8 @@ snapshots:
yaml@2.8.4: {}
yaml@2.9.0: {}
yargs-parser@21.1.1: {}
yargs@17.7.2:
@@ -12445,6 +12992,8 @@ snapshots:
yocto-queue@0.1.0: {}
yocto-queue@1.2.2: {}
yocto-spinner@1.2.0:
dependencies:
yoctocolors: 2.1.2
@@ -0,0 +1,34 @@
-- v1.4.40 SB-10 — per-user AI consent receipts.
--
-- Append-only audit trail backing the iOS onboarding consent flow plus
-- the operator's GDPR Art. 7 + App-Store Guideline 5.1.2(i) burden of
-- proof for "data-collection consent is verifiable". A user revoking +
-- re-granting consent mints a fresh row; the latest non-revoked row
-- per (user, kind) is the source of truth for "is AI active right
-- now".
--
-- Additive only. Idempotent guards mirror 0067 / 0070 / 0071.
CREATE TABLE IF NOT EXISTS "consent_receipts" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"kind" TEXT NOT NULL,
"artefact" TEXT NOT NULL,
"signed_at" TIMESTAMP(3) NOT NULL,
"revoked_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "consent_receipts_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "consent_receipts_user_id_created_at_idx"
ON "consent_receipts" ("user_id", "created_at" DESC);
DO $$ BEGIN
ALTER TABLE "consent_receipts"
ADD CONSTRAINT "consent_receipts_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
+43
View File
@@ -295,6 +295,13 @@ model User {
// keep the row in sync so `/api/medications/intake?scope=compliance`
// and `computeUserHealthScoreFastPath` skip the per-event findMany.
medicationComplianceRollups MedicationComplianceRollup[]
// v1.4.40 SB-10 — AI consent receipts the iOS app POSTs when the user
// grants AI processing during onboarding. Persisted audit trail
// covers App-Store Guideline 5.1.2(i) ("data-collection consent must
// be verifiable") plus the operator's GDPR Art. 7 documentation
// burden. Append-only — a user revoking + re-granting consent mints
// a fresh row so the timeline reads chronologically.
consentReceipts ConsentReceipt[]
@@map("users")
}
@@ -1917,3 +1924,39 @@ model CoachUsage {
@@index([userId, dateKey])
@@map("coach_usage")
}
// ─── Consent Receipts (v1.4.40 SB-10) ─────────────────────────
//
// Per-user audit trail of AI-processing consent grants. The iOS app
// posts a fresh row whenever the user opts in to off-device AI flows
// during onboarding or via Settings; append-only so a revoke + re-grant
// cycle reads chronologically. Persists the raw signed artefact (PDF
// receipt OR signed JWT — caller's choice) the App-Store Guideline
// 5.1.2(i) audit needs to be verifiable. The latest row per user is
// the source of truth for "is AI active right now" decisions; older
// rows are kept for the GDPR Art. 7 burden of proof.
//
// `kind` is a free-text discriminator the iOS / web client uses to
// distinguish "ai_full" / "ai_insights_only" / future scopes without
// requiring a schema migration per new tier.
model ConsentReceipt {
id String @id @default(cuid())
userId String @map("user_id")
kind String
/// Raw consent artefact — either a base64-encoded PDF or a signed
/// JWT. The server does not parse this payload; the audit trail is
/// the storage itself plus the `signedAt` timestamp.
artefact String @db.Text
/// Wall-clock the client recorded the user's tap. UTC ISO-8601.
signedAt DateTime @map("signed_at")
/// Optional revocation marker — set when a follow-up "revoke" row
/// is minted so the latest-per-user lookup can shortcut to "no
/// active consent" without diffing the kind discriminator.
revokedAt DateTime? @map("revoked_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt(sort: Desc)])
@@map("consent_receipts")
}
+1 -1
View File
@@ -19,7 +19,7 @@ import { prisma } from "@/lib/db";
import {
ALL_GRANULARITIES,
recomputeUserRollups,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
interface CliOptions {
userId: string | null;
@@ -76,11 +76,11 @@ vi.mock("@/lib/logging/context", async () => {
// The three fast-paths and the rollup helpers are exercised by their
// own tests; here we stub them so the route's per-type read is the
// only Prisma traffic the test observes.
vi.mock("@/lib/measurements/rollups", () => ({
vi.mock("@/lib/rollups/measurement-rollups", () => ({
ensureUserRollupsFresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
probeRollupCoverage: vi.fn().mockResolvedValue(new Map()),
}));
@@ -150,10 +150,10 @@ beforeEach(async () => {
// module scope only sets the initial implementation, and the slim
// slice test must observe a resolved value not `undefined`.
const { ensureUserRollupsFresh } = await import(
"@/lib/measurements/rollups"
"@/lib/rollups/measurement-rollups"
);
const { probeRollupCoverage } = await import(
"@/lib/measurements/rollup-coverage"
"@/lib/rollups/measurement-coverage"
);
const { computeBpInTargetFastPath } = await import(
"@/lib/analytics/bp-in-target-fast-path"
@@ -0,0 +1,116 @@
/**
* v1.4.40 W-INSIGHTS SB-7 — pin the four discovery branches of
* `GET /api/auth/registration-status`.
*
* The route is read-only; the iOS sign-in flow + the web onboarding
* dispatcher both depend on its envelope shape (`registrationEnabled`
* boolean) staying byte-stable. A regression that flipped the
* fail-closed branch to fail-open would silently allow registration on
* a self-hosted deployment whose admin set `registrationEnabled: false`
* and an upstream DB blip lost the row.
*
* Four branches walked:
*
* 1. **Singleton row exists with `registrationEnabled: true`** →
* envelope reports `true`.
* 2. **Singleton row exists with `registrationEnabled: false`** →
* envelope reports `false`. (Admin-disabled tenant — invite-only.)
* 3. **Singleton row missing (`findUnique` returns `null`)** →
* envelope reports the schema default `true` (open registration —
* the no-config baseline for a fresh self-hosted instance).
* 4. **`findUnique` throws** (DB outage / connection drop) → envelope
* fails closed with `false`. Critical: a DB blip on a tenant
* that has disabled registration must NOT unlock the sign-up
* flow.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "@/app/api/auth/registration-status/route";
import { prisma } from "@/lib/db";
const callGet = GET as unknown as (...args: never[]) => Promise<Response>;
interface RegistrationStatusBody {
data: { registrationEnabled: boolean };
}
beforeEach(() => {
vi.resetAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("GET /api/auth/registration-status", () => {
it("reports `true` when the singleton row carries registrationEnabled: true", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as RegistrationStatusBody;
expect(body.data.registrationEnabled).toBe(true);
});
it("reports `false` when the singleton row carries registrationEnabled: false (admin-disabled tenant)", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({
id: "singleton",
registrationEnabled: false,
} as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as RegistrationStatusBody;
expect(body.data.registrationEnabled).toBe(false);
});
it("falls back to the schema default `true` when the singleton row is missing (fresh self-hosted instance)", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as RegistrationStatusBody;
expect(body.data.registrationEnabled).toBe(true);
});
it("fails closed with `false` when the DB lookup throws (defense-in-depth against silent unlock)", async () => {
vi.mocked(prisma.appSettings.findUnique).mockRejectedValue(
new Error("connection terminated"),
);
const res = await callGet();
expect(res.status).toBe(200);
const body = (await res.json()) as RegistrationStatusBody;
expect(body.data.registrationEnabled).toBe(false);
});
});
+85
View File
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
/**
* SB-4 (v1.4.40) — Apple App Site Association (AASA) handler contract.
*
* iOS Universal Links and Web Credentials both fetch
* `/.well-known/apple-app-site-association` without credentials and feed
* the body to Apple's swcd / aasa-validator. Both pieces of Apple
* tooling are strict about three things:
*
* 1. The exact JSON shape — `applinks.details[].appID` (the
* `<TeamID>.<BundleID>` prefix) plus `applinks.details[].paths`
* (the URL-prefix allow-list), and `webcredentials.apps` (the same
* App ID prefix) for passkey origin-sharing.
* 2. The `Content-Type` header — must be `application/json` *without*
* a `charset` parameter. `application/json; charset=utf-8` makes
* Apple's CDN proxy refuse to mirror the file, which silently
* breaks Universal Links on every device.
* 3. The response answers 200 to an unauthenticated GET (covered by
* `proxy-well-known-public.test.ts` at the proxy layer).
*
* This file pins (1) and (2). Any drift means iOS PB30 (App-Store
* submission) regresses without a CI signal.
*/
import { GET } from "@/app/.well-known/apple-app-site-association/route";
const APP_ID = "S8WDX4W5KX.dev.healthlog.app";
describe("/.well-known/apple-app-site-association (SB-4)", () => {
it("answers 200", async () => {
const res = GET();
expect(res.status).toBe(200);
});
it("uses Content-Type: application/json with no charset", async () => {
const res = GET();
const contentType = res.headers.get("content-type");
// Apple's aasa-validator + swcd reject any `charset=…` parameter
// on the AASA file. The handler MUST emit the bare media type.
expect(contentType).toBe("application/json");
});
it("sets a Cache-Control directive that allows mirroring", async () => {
const res = GET();
const cacheControl = res.headers.get("cache-control");
expect(cacheControl).toBeTruthy();
// Public + max-age so Apple's CDN (one-hour TTL) can mirror the
// file without falling back to private/no-store behaviour.
expect(cacheControl).toMatch(/public/);
expect(cacheControl).toMatch(/max-age=\d+/);
});
it("emits the SB-4 payload shape exactly", async () => {
const res = GET();
const body = await res.json();
expect(body).toEqual({
applinks: {
details: [
{
appID: APP_ID,
paths: ["*"],
},
],
},
webcredentials: {
apps: [APP_ID],
},
});
});
it("uses the same App ID prefix for applinks and webcredentials", async () => {
// Apple's passkey origin-pairing requires the iOS bundle and the
// website origin to advertise the same App ID. Splitting these two
// entries (e.g. a stale Team ID on one side) breaks passkey login
// without a redirect / status-code signal — the only symptom is a
// silent fall-back to password login on iOS.
const res = GET();
const body = (await res.json()) as {
applinks: { details: Array<{ appID: string }> };
webcredentials: { apps: string[] };
};
expect(body.applinks.details[0].appID).toBe(body.webcredentials.apps[0]);
});
});
@@ -6,14 +6,15 @@ import { NextResponse } from "next/server";
* Apple fetches `/.well-known/apple-app-site-association` over HTTPS
* without credentials, with no extension and with a strict
* `application/json` Content-Type expectation. iOS uses the body to:
* - Wire Universal Links so HealthLog URLs open straight in the iOS
* app instead of Safari (SB-4, v1.4.40). The `["*"]` matcher accepts
* every path on the host — the iOS side decides which routes it
* actually handles via its scene delegate.
* - Wire Web Credentials so the website origin and the iOS bundle
* share passkey ceremonies (HealthLog passkeys live at
* `/api/auth/passkey/*`, so the origins must match the same App ID).
* - Wire Universal Links — out of scope for the first cut, hence the
* empty `applinks.details` array. Future entries here pick up
* deep-linking once the iOS side opts in.
*
* The `webcredentials.apps` entry is the App ID prefix
* The `appID` / `webcredentials.apps` entry is the App ID prefix
* (`<TeamID>.<BundleID>`) for the HealthLog iOS app. Apple bundles the
* file on its CDN proxy
* (`https://app-site-association.cdn-apple.com/a/v1/<host>`) and serves
@@ -23,13 +24,19 @@ import { NextResponse } from "next/server";
* Both `healthlog.bombeck.io` (maintainer prod) and `demo.healthlog.dev`
* (demo) share this handler — the response is host-independent.
*/
const AASA_APP_ID = "S8WDX4W5KX.dev.healthlog.app";
const AASA = {
applinks: {
apps: [],
details: [],
details: [
{
appID: AASA_APP_ID,
paths: ["*"],
},
],
},
webcredentials: {
apps: ["S8WDX4W5KX.dev.healthlog.app"],
apps: [AASA_APP_ID],
},
};
@@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
/**
* v1.4.40 W-RSC — dashboard Suspense boundaries.
*
* Audit-H2 flagged "no `<Suspense>` boundaries anywhere in the app".
* The v1.4.40 W-RSC wave wraps every dashboard tile cell and every
* chart cell in a per-cell `<Suspense>` boundary so the composition
* reads as a grid of independently-suspending islands rather than a
* flat client tree with shared loading semantics.
*
* Today the boundary is a structural no-op for the dynamic-loaded
* charts because their loading skeleton lives inside the
* `next/dynamic({ loading: <ChartSkeleton/> })` contract. The benefit
* is future-proofing: any descendant that later suspends (an
* `useSuspenseQuery` migration, an RSC hoist of a static legend) gets
* its own fallback without re-architecting the row.
*
* The test pins the structural presence by greppig the page source —
* a future refactor that drops the boundary lands a failing test
* rather than silently regressing the streaming-composition contract.
*/
const ROOT = join(__dirname, "../../..");
const PAGE_PATH = join(ROOT, "src/app/page.tsx");
function load(path: string): string {
return readFileSync(path, "utf8");
}
describe("v1.4.40 — dashboard per-cell Suspense boundaries", () => {
it("imports Suspense from React", () => {
const src = load(PAGE_PATH);
expect(src).toMatch(/from\s+"react"/);
expect(src).toMatch(/\bSuspense\b/);
});
it("wraps the chart-row cell in a `<Suspense fallback={<ChartSkeleton />}>` boundary", () => {
const src = load(PAGE_PATH);
// `<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>`
// exact contract: the chart row's per-cell fallback uses the same
// ChartSkeleton primitive the dynamic-loaded charts already paint
// during JS chunk resolution.
expect(src).toMatch(
/<Suspense\s+fallback=\{<ChartSkeleton\s*\/>\}>\s*\{entry\.node\}\s*<\/Suspense>/,
);
});
it("wraps each tile-strip cell in a `<Suspense>` boundary", () => {
const src = load(PAGE_PATH);
// `<Suspense fallback={null}>{entry.node}</Suspense>` — the tile
// body is synchronous today so a `null` fallback never paints,
// but the boundary primes the row for a future RSC hoist of any
// tile slot. Pinning the literal keeps the structural contract
// visible in CI.
expect(src).toMatch(
/<Suspense\s+fallback=\{null\}>\s*\{entry\.node\}\s*<\/Suspense>/,
);
});
it("hoists DASHBOARD_QUERY_OPTS to module scope (audit-M2)", () => {
const src = load(PAGE_PATH);
// Module-scope declaration — search outside the component body.
// Pre-fix the const was declared inside `DashboardPage` so every
// render created a fresh `{}` reference.
const moduleSliceEnd = src.indexOf("export default function DashboardPage");
expect(moduleSliceEnd).toBeGreaterThan(0);
const moduleSlice = src.slice(0, moduleSliceEnd);
expect(moduleSlice).toMatch(/const\s+DASHBOARD_QUERY_OPTS\s*=\s*\{/);
});
it("memoises the hour-of-day derivation against user.timezone (audit-H4)", () => {
const src = load(PAGE_PATH);
// The greeting hour only changes when the user's timezone changes
// — a `useMemo` keyed on the lifted `userTimezone` local keeps the
// `Intl.DateTimeFormat` instantiation off the per-render hot path.
// Post-W-INFRA Thread 2: `user?.timezone` is lifted to a `userTimezone`
// local one line above the `useMemo` so the dep array stays stable.
expect(src).toMatch(
/const\s+hour\s*=\s*useMemo\([\s\S]*?\[\s*userTimezone\s*\][\s\S]*?\);/,
);
});
});
@@ -43,12 +43,12 @@ import {
summarizeBackup,
type BackupSummary,
} from "@/lib/validations/backup";
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
recomputeUserMedicationCompliance,
MEDICATION_COMPLIANCE_BACKFILL_DAYS,
} from "@/lib/medications/compliance-rollups";
import { recomputeUserRollups } from "@/lib/measurements/rollups";
} from "@/lib/rollups/medication-compliance-rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
export const dynamic = "force-dynamic";
+1 -1
View File
@@ -38,7 +38,7 @@ import { annotate } from "@/lib/logging/context";
import {
enqueueBootTimeRollupBackfill,
recomputeUserRollups,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
interface RecomputeBody {
userId?: string;
+68 -1
View File
@@ -73,7 +73,7 @@ vi.mock("next/headers", () => ({
})),
}));
import { GET } from "../route";
import { ANALYTICS_TYPE_FETCH_CONCURRENCY, GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
@@ -376,4 +376,71 @@ describe("GET /api/analytics", () => {
expect(body.data.lastSeenByType.WEIGHT?.daysAgo).toBeLessThanOrEqual(6);
expect(body.data.lastSeenByType.PULSE).toBeNull();
});
// v1.4.40 W-POOL — pin the bounded-concurrency cap on the per-type
// fan-out inside `buildAnalyticsResponse`. The v1.4.39 empirical
// trace (`.planning/round-v1439-empirical-trace.md` §B1) showed the
// raw `Promise.all` over `fetchMeasurementSeriesChunked` holding
// ≥8 of the default-10 pg.Pool slots for 6.5 s on a power-user cold
// mount. The p-limit(4) wrapper keeps the same total work but caps
// inflight Prisma round-trips for this branch at 4.
//
// The assertion instruments the `prisma.measurement.findMany` mock
// to track peak inflight count + total batches. With N≥15 types and
// a 20 ms per-call delay, an unbounded `Promise.all` would peak at
// ~15 and finish in ~20 ms; the bounded version peaks at exactly 4
// and finishes in ~80 ms (4 batches × 20 ms ceiling).
it("caps per-type Prisma fan-out at ANALYTICS_TYPE_FETCH_CONCURRENCY", async () => {
// Default-slice path requires no coverage so the route falls into
// the live per-type loop where the fan-out lives.
vi.mocked(prisma.measurementRollup.findMany).mockResolvedValue([] as never);
let inflight = 0;
let peak = 0;
let calls = 0;
vi.mocked(prisma.measurement.findMany).mockImplementation(
(async () => {
// Only count the per-type chunked fan-out calls (not the
// narrower glucose / sleep-stage `findMany` reads inside the
// same route; those run sequentially AFTER the fan-out
// completes so they never overlap with peak inflight).
calls += 1;
inflight += 1;
peak = Math.max(peak, inflight);
// 20 ms is large enough to make the bounded vs unbounded
// distinction observable but small enough to keep the suite
// under a second.
await new Promise((resolve) => setTimeout(resolve, 20));
inflight -= 1;
// Return an empty page so the chunked reader exits after one
// round-trip per type.
return [] as never;
}) as never,
);
const t0 = Date.now();
const res = await (
GET as unknown as (...args: never[]) => Promise<Response>
)();
const elapsed = Date.now() - t0;
expect(res.status).toBe(200);
// The cap itself — peak inflight Prisma reads against
// `measurements` never crosses the configured ceiling.
expect(ANALYTICS_TYPE_FETCH_CONCURRENCY).toBe(4);
expect(peak).toBeLessThanOrEqual(ANALYTICS_TYPE_FETCH_CONCURRENCY);
// Sanity: more than one type was actually walked, otherwise the
// bound is unfalsifiable.
expect(calls).toBeGreaterThanOrEqual(8);
// Wall-clock proves the lanes are saturated rather than serialised
// by accident. 4 lanes × 20 ms × ⌈calls/4⌉ ≈ 60-100 ms; an
// unbounded fan-out would finish in ~25-40 ms; a fully serial
// walk would take calls × 20 ms ≈ 300+ ms. The window catches
// both regressions.
expect(elapsed).toBeGreaterThanOrEqual(40);
expect(elapsed).toBeLessThan(calls * 20);
});
});
+43 -8
View File
@@ -1,3 +1,4 @@
import pLimit from "p-limit";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
@@ -14,8 +15,8 @@ import type {
} from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
import { ensureUserRollupsFresh } from "@/lib/measurements/rollups";
import { probeRollupCoverage } from "@/lib/measurements/rollup-coverage";
import { ensureUserRollupsFresh } from "@/lib/rollups/measurement-rollups";
import { probeRollupCoverage } from "@/lib/rollups/measurement-coverage";
import { computeBpInTargetFastPath } from "@/lib/analytics/bp-in-target-fast-path";
import { computeUserHealthScoreFastPath } from "@/lib/analytics/health-score-fast-path";
import { computeCorrelationHypothesesFastPath } from "@/lib/analytics/correlations-fast-path";
@@ -27,6 +28,32 @@ import {
export const dynamic = "force-dynamic";
/**
* v1.4.40 W-POOL — bound the per-type analytics fan-out below to ≤ 4
* concurrent Prisma round-trips.
*
* The v1.4.39 empirical cold-mount trace
* (`.planning/round-v1439-empirical-trace.md` § B1) recorded thick
* `/api/analytics` running 15× `fetchMeasurementSeriesChunked` in
* parallel via a bare `Promise.all` (this file's `route.ts:261-269`
* pre-fix), each pulling chunked findMany pages of 5 000 rows. The
* combined fan-out held ≥ 8 of the default-10 `pg.Pool` slots for
* 6.5 s on Marc's 347k-row tenant, starving every other Wave-B / C
* dashboard query.
*
* Capping the concurrency to 4 keeps the same total work but holds
* at most 4 pool slots for the analytics branch at any moment. With
* the W-POOL ceiling raised to 20 (`src/lib/db.ts:getPoolMax`),
* every other dashboard query has ≥ 16 free slots regardless of how
* many types analytics is mid-walk over. The thick endpoint pays a
* small (~10–15 %) absolute wall-clock cost on a single cold mount —
* 15 chunks across 4 lanes = 4 batches instead of 1 — but is already
* wrapped in the 60s `caches.analytics` LRU so the user-visible
* impact is only the very first mount, in exchange for the chart-tile
* burst paying ~5 s less of pool wait time on every mount thereafter.
*/
export const ANALYTICS_TYPE_FETCH_CONCURRENCY = 4;
/**
* v1.4.33 C1 — pull `?slice=…` from either a NextRequest (the
* route's production wrapper) or a plain `Request` (the integration
@@ -181,12 +208,9 @@ async function buildAnalyticsResponse(user: AuthedUser) {
// cost flat across the three branches.
const coverage = await probeRollupCoverage(user.id);
// v1.4.25 W7b — every day-bucket call inside this route now honours
// the user's display timezone. The legacy `berlinDayKey()` import
// remains for sleep-stage and correlation paths that share their
// helper signature with non-tz-aware code (`computeSleepStageBreakdown`
// is called with a userId only); the per-call sites below all pass
// `userTz` through `userDayKey()`.
// v1.4.25 W7b — every day-bucket call inside this route honours the
// user's display timezone via `userDayKey(date, userTz)` (the legacy
// `berlinDayKey()` helper was retired in v1.4.40).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
// Derived from canonical enum so a new measurement type is auto-summarised
@@ -258,8 +282,15 @@ async function buildAnalyticsResponse(user: AuthedUser) {
const lastSeenByType: Record<string, { lastSeenAt: string } | null> = {};
let totalRowsReadForAggregate = 0;
// v1.4.40 W-POOL — bounded-concurrency wrapper for the 15-way
// per-type fan-out below. See `ANALYTICS_TYPE_FETCH_CONCURRENCY`
// above + `.planning/round-v1439-empirical-trace.md` § B1 for the
// pool-saturation root cause. Same total work, ≤ 4 pool slots held
// by this branch at any moment.
const typeFetchLimit = pLimit(ANALYTICS_TYPE_FETCH_CONCURRENCY);
const measurementsByType = await Promise.all(
types.map((type) =>
typeFetchLimit(() =>
fetchMeasurementSeriesChunked(user.id, type, {
includeSleepStage: true,
// v1.4.39 W-SINCE — trailing 425-day floor for the live read.
@@ -348,6 +379,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
summary: summarize(datapoints),
};
}),
),
),
);
@@ -477,6 +509,7 @@ async function buildAnalyticsResponse(user: AuthedUser) {
userId: user.id,
type: "BLOOD_GLUCOSE",
measuredAt: { gte: glucoseSince },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { value: true, measuredAt: true, glucoseContext: true },
@@ -586,6 +619,7 @@ async function computeSleepStageBreakdown(
type: "SLEEP_DURATION",
sleepStage: { not: null },
measuredAt: { gte: since },
deletedAt: null,
},
select: { value: true, measuredAt: true, sleepStage: true },
});
@@ -695,6 +729,7 @@ async function fetchMeasurementSeriesChunked(
userId,
type,
...(options.since ? { measuredAt: { gte: options.since } } : {}),
deletedAt: null,
},
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: {
@@ -0,0 +1,170 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
consentReceipt: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/consent/ai", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears every mock's implementation, including the
// top-level `vi.mock(...).mockResolvedValue(undefined)` for audit.
// Re-arm it here so the fire-and-forget `.catch()` in the route
// doesn't crash on an undefined return.
vi.mocked(auditLog).mockResolvedValue(undefined);
});
describe("POST /api/consent/ai", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
req({
kind: "ai_full",
artefact: "PDF",
signedAt: "2026-05-18T10:00:00.000Z",
}),
);
expect(res.status).toBe(401);
});
it("returns 400 for an invalid kind", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({
kind: "ai_total_world_domination",
artefact: "PDF",
signedAt: "2026-05-18T10:00:00.000Z",
}),
);
expect(res.status).toBe(400);
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("returns 400 for a non-ISO signedAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(
req({
kind: "ai_full",
artefact: "PDF",
signedAt: "yesterday",
}),
);
expect(res.status).toBe(400);
});
it("returns 400 when artefact exceeds the 64 KB cap", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const oversize = "A".repeat(64 * 1024 + 1);
const res = await POST(
req({
kind: "ai_full",
artefact: oversize,
signedAt: "2026-05-18T10:00:00.000Z",
}),
);
expect(res.status).toBe(400);
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
});
it("inserts a row and returns the receipt id + serialised row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const signedAt = new Date("2026-05-18T10:00:00.000Z");
const createdAt = new Date("2026-05-18T10:00:01.000Z");
vi.mocked(prisma.consentReceipt.create).mockResolvedValue({
id: "rcpt-1",
userId: "user-1",
kind: "ai_full",
artefact: "PDF",
signedAt,
revokedAt: null,
createdAt,
} as never);
const res = await POST(
req({
kind: "ai_full",
artefact: "PDF",
signedAt: signedAt.toISOString(),
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
id: string;
receipt: {
id: string;
userId: string;
kind: string;
signedAt: string;
revokedAt: string | null;
createdAt: string;
};
};
};
expect(body.data.id).toBe("rcpt-1");
expect(body.data.receipt.kind).toBe("ai_full");
expect(body.data.receipt.userId).toBe("user-1");
expect(body.data.receipt.signedAt).toBe(signedAt.toISOString());
expect(body.data.receipt.revokedAt).toBeNull();
// `artefact` is intentionally stripped from the response — verify
// it is not echoed back over the wire.
expect(body.data.receipt).not.toHaveProperty("artefact");
expect(prisma.consentReceipt.create).toHaveBeenCalledWith({
data: {
userId: "user-1",
kind: "ai_full",
artefact: "PDF",
signedAt,
},
});
});
});
@@ -0,0 +1,393 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
consentReceipt: {
create: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, DELETE } from "../route";
import { POST as POST_GRANT } from "../../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
// `resetAllMocks` clears the top-level `mockResolvedValue(undefined)`
// on `auditLog`. Re-arm so the route's fire-and-forget `.catch()`
// chain doesn't NPE on an undefined return.
vi.mocked(auditLog).mockResolvedValue(undefined);
});
describe("GET /api/consent/ai/latest", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
);
expect(res.status).toBe(401);
});
it("returns 400 for an unknown kind", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
new NextRequest("http://localhost/api/consent/ai/latest?kind=invalid"),
);
expect(res.status).toBe(400);
});
it("returns null receipt when nothing has been granted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { kind: string; receipt: unknown | null };
};
expect(body.data.kind).toBe("ai_full");
expect(body.data.receipt).toBeNull();
});
it("returns the latest non-revoked receipt by kind", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const signedAt = new Date("2026-05-18T10:00:00.000Z");
const createdAt = new Date("2026-05-18T10:00:01.000Z");
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
id: "rcpt-1",
userId: "user-1",
kind: "ai_full",
artefact: "PDF",
signedAt,
revokedAt: null,
createdAt,
} as never);
const res = await GET(
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { kind: string; receipt: { id: string; kind: string } | null };
};
expect(body.data.receipt?.id).toBe("rcpt-1");
expect(body.data.receipt?.kind).toBe("ai_full");
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
orderBy: { createdAt: "desc" },
});
});
it("returns the full keyspace when called without ?kind", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const signedAt = new Date("2026-05-18T10:00:00.000Z");
const createdAt = new Date("2026-05-18T10:00:01.000Z");
vi.mocked(prisma.consentReceipt.findMany).mockResolvedValue([
{
id: "rcpt-full",
userId: "user-1",
kind: "ai_full",
artefact: "PDF",
signedAt,
revokedAt: null,
createdAt,
},
] as never);
const res = await GET(
new NextRequest("http://localhost/api/consent/ai/latest"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Record<string, { id: string } | null>;
};
expect(body.data.ai_full?.id).toBe("rcpt-full");
expect(body.data.ai_coach).toBeNull();
expect(body.data.ai_insights_only).toBeNull();
});
});
describe("DELETE /api/consent/ai/latest", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
method: "DELETE",
}),
);
expect(res.status).toBe(401);
});
it("marks the latest receipt as revoked and returns it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const signedAt = new Date("2026-05-18T10:00:00.000Z");
const createdAt = new Date("2026-05-18T10:00:01.000Z");
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue({
id: "rcpt-1",
userId: "user-1",
kind: "ai_full",
artefact: "PDF",
signedAt,
revokedAt: null,
createdAt,
} as never);
vi.mocked(prisma.consentReceipt.update).mockResolvedValue({
id: "rcpt-1",
userId: "user-1",
kind: "ai_full",
artefact: "PDF",
signedAt,
revokedAt,
createdAt,
} as never);
const res = await DELETE(
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
method: "DELETE",
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { kind: string; receipt: { id: string; revokedAt: string } | null };
};
expect(body.data.receipt?.id).toBe("rcpt-1");
expect(body.data.receipt?.revokedAt).toBe(revokedAt.toISOString());
expect(prisma.consentReceipt.update).toHaveBeenCalledWith({
where: { id: "rcpt-1" },
data: { revokedAt: expect.any(Date) },
});
});
it("is idempotent — returns null receipt without writing when nothing to revoke", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
const res = await DELETE(
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
method: "DELETE",
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { kind: string; receipt: unknown | null };
};
expect(body.data.receipt).toBeNull();
expect(prisma.consentReceipt.update).not.toHaveBeenCalled();
});
it("master toggle revokes the latest active row per kind", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const signedAt = new Date("2026-05-18T10:00:00.000Z");
const createdAt = new Date("2026-05-18T10:00:01.000Z");
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
// First call (ai_full) finds a row, second (ai_insights_only)
// finds none, third (ai_coach) finds a row.
vi.mocked(prisma.consentReceipt.findFirst)
.mockResolvedValueOnce({
id: "rcpt-full",
userId: "user-1",
kind: "ai_full",
artefact: "PDF",
signedAt,
revokedAt: null,
createdAt,
} as never)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: "rcpt-coach",
userId: "user-1",
kind: "ai_coach",
artefact: "PDF",
signedAt,
revokedAt: null,
createdAt,
} as never);
vi.mocked(prisma.consentReceipt.update).mockImplementation(
((args: { where: { id: string }; data: { revokedAt: Date } }) =>
Promise.resolve({
id: args.where.id,
userId: "user-1",
kind: args.where.id === "rcpt-full" ? "ai_full" : "ai_coach",
artefact: "PDF",
signedAt,
revokedAt,
createdAt,
})) as never,
);
const res = await DELETE(
new NextRequest("http://localhost/api/consent/ai/latest", {
method: "DELETE",
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { revoked: Array<{ kind: string; receipt: { id: string } }> };
};
expect(body.data.revoked).toHaveLength(2);
expect(body.data.revoked.map((r) => r.kind).sort()).toEqual([
"ai_coach",
"ai_full",
]);
});
});
describe("append-only invariant", () => {
/**
* v1.4.40 SB-10 — the receipts table is the legal audit trail.
* Revoking + re-granting must leave the original row in place
* (just with `revokedAt` set) and mint a fresh row for the new
* grant. The latest active receipt after the cycle is the newest
* one, not the original.
*/
it("revoke + re-grant leaves both rows; latestActive points at the re-grant", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const signedAt1 = new Date("2026-05-18T10:00:00.000Z");
const createdAt1 = new Date("2026-05-18T10:00:01.000Z");
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
const signedAt2 = new Date("2026-05-18T12:00:00.000Z");
const createdAt2 = new Date("2026-05-18T12:00:01.000Z");
// Step 1: initial grant.
vi.mocked(prisma.consentReceipt.create).mockResolvedValueOnce({
id: "rcpt-1",
userId: "user-1",
kind: "ai_full",
artefact: "PDF-v1",
signedAt: signedAt1,
revokedAt: null,
createdAt: createdAt1,
} as never);
const grant1 = await POST_GRANT(
new NextRequest("http://localhost/api/consent/ai", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
kind: "ai_full",
artefact: "PDF-v1",
signedAt: signedAt1.toISOString(),
}),
}),
);
expect(grant1.status).toBe(200);
// Step 2: revoke. The DELETE handler reads + updates.
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
id: "rcpt-1",
userId: "user-1",
kind: "ai_full",
artefact: "PDF-v1",
signedAt: signedAt1,
revokedAt: null,
createdAt: createdAt1,
} as never);
vi.mocked(prisma.consentReceipt.update).mockResolvedValueOnce({
id: "rcpt-1",
userId: "user-1",
kind: "ai_full",
artefact: "PDF-v1",
signedAt: signedAt1,
revokedAt,
createdAt: createdAt1,
} as never);
const revokeRes = await DELETE(
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full", {
method: "DELETE",
}),
);
expect(revokeRes.status).toBe(200);
expect(prisma.consentReceipt.update).toHaveBeenCalledTimes(1);
// Critical invariant — `delete` was NEVER called on the receipt.
// Only an update setting `revokedAt`. The old row stays in place.
const updateCall = vi.mocked(prisma.consentReceipt.update).mock.calls[0][0];
expect(updateCall).toMatchObject({
where: { id: "rcpt-1" },
data: { revokedAt: expect.any(Date) },
});
// Step 3: re-grant mints a fresh row.
vi.mocked(prisma.consentReceipt.create).mockResolvedValueOnce({
id: "rcpt-2",
userId: "user-1",
kind: "ai_full",
artefact: "PDF-v2",
signedAt: signedAt2,
revokedAt: null,
createdAt: createdAt2,
} as never);
const grant2 = await POST_GRANT(
new NextRequest("http://localhost/api/consent/ai", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
kind: "ai_full",
artefact: "PDF-v2",
signedAt: signedAt2.toISOString(),
}),
}),
);
expect(grant2.status).toBe(200);
expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(2);
// Step 4: GET latest now points at the re-grant.
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValueOnce({
id: "rcpt-2",
userId: "user-1",
kind: "ai_full",
artefact: "PDF-v2",
signedAt: signedAt2,
revokedAt: null,
createdAt: createdAt2,
} as never);
const latest = await GET(
new NextRequest("http://localhost/api/consent/ai/latest?kind=ai_full"),
);
const body = (await latest.json()) as {
data: { receipt: { id: string } | null };
};
expect(body.data.receipt?.id).toBe("rcpt-2");
});
});
+143
View File
@@ -0,0 +1,143 @@
/**
* v1.4.40 SB-10 — AI consent latest-receipt reader + revoke endpoint.
*
* GET /api/consent/ai/latest?kind=ai_full
* Returns the latest non-revoked receipt for that kind, or null.
*
* GET /api/consent/ai/latest
* Returns the latest non-revoked receipt per kind, keyed by kind.
*
* DELETE /api/consent/ai/latest?kind=ai_full
* Marks the latest active receipt for that kind as revoked.
*
* DELETE /api/consent/ai/latest
* Master toggle — revokes the latest active receipt across every
* consent kind. Returns the list of revoked rows.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
consentKindEnum,
consentLatestQuery,
type ConsentKind,
} from "@/lib/validations/consent";
import {
latestActiveReceipt,
latestActiveReceiptsByKind,
revokeLatest,
} from "@/lib/consent/receipts";
import { serialiseReceipt } from "../route";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = consentLatestQuery.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 400);
}
const { kind } = parsed.data;
if (kind) {
const receipt = await latestActiveReceipt(user.id, kind);
annotate({
action: { name: "consent.ai.latest" },
meta: { kind, found: receipt != null },
});
return apiSuccess({
kind,
receipt: receipt ? serialiseReceipt(receipt) : null,
});
}
const byKind = await latestActiveReceiptsByKind(user.id);
// 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> = {
ai_full: byKind.ai_full ? serialiseReceipt(byKind.ai_full) : null,
ai_insights_only: byKind.ai_insights_only
? serialiseReceipt(byKind.ai_insights_only)
: null,
ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null,
};
annotate({
action: { name: "consent.ai.latest" },
meta: {
kinds_active: Object.entries(result)
.filter(([, v]) => v != null)
.map(([k]) => k),
},
});
return apiSuccess(result);
});
export const DELETE = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = consentLatestQuery.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 400);
}
const { kind } = parsed.data;
if (kind) {
const revoked = await revokeLatest(user.id, kind);
if (!revoked) {
// Idempotent: "no active receipt to revoke" is success-shaped.
// The iOS toggle hammers this on every flip; returning 404
// would surface as a spurious error in the client.
annotate({
action: { name: "consent.ai.revoke" },
meta: { kind, revoked: false },
});
return apiSuccess({ kind, receipt: null });
}
auditLog("consent.ai.revoke", {
userId: user.id,
details: { kind, receiptId: revoked.id },
}).catch(() => {});
annotate({
action: { name: "consent.ai.revoke" },
meta: { kind, receiptId: revoked.id },
});
return apiSuccess({ kind, receipt: serialiseReceipt(revoked) });
}
// No kind specified — master "AI deaktivieren" toggle. Revoke the
// latest active row for every kind that currently has one.
const revokedRows: Array<{
kind: ConsentKind;
receipt: ReturnType<typeof serialiseReceipt>;
}> = [];
for (const k of consentKindEnum.options) {
const r = await revokeLatest(user.id, k);
if (r) {
revokedRows.push({ kind: k, receipt: serialiseReceipt(r) });
auditLog("consent.ai.revoke", {
userId: user.id,
details: { kind: k, receiptId: r.id },
}).catch(() => {});
}
}
annotate({
action: { name: "consent.ai.revoke" },
meta: {
kinds_revoked: revokedRows.map((r) => r.kind),
count: revokedRows.length,
},
});
return apiSuccess({ revoked: revokedRows });
});
+84
View File
@@ -0,0 +1,84 @@
/**
* v1.4.40 SB-10 — AI consent receipts.
*
* POST /api/consent/ai
* Body: { kind, artefact, signedAt }
* Persists a fresh consent receipt. Returns the inserted row.
*
* Reader + revoke handlers live in `./latest/route.ts`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { consentPostBody } from "@/lib/validations/consent";
import { createReceipt } from "@/lib/consent/receipts";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
if (error) return error;
const parsed = consentPostBody.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 400);
}
const { kind, artefact, signedAt } = parsed.data;
const receipt = await createReceipt(user.id, kind, artefact, signedAt);
// Audit trail — separate from the row itself so the legal team can
// reconstruct "who minted this and when" even if the receipts table
// is rebuilt from a backup. Fire-and-forget; audit failures must
// never bubble into a user-facing 500.
auditLog("consent.ai.grant", {
userId: user.id,
details: { kind, receiptId: receipt.id },
}).catch(() => {});
annotate({
action: { name: "consent.ai.grant" },
meta: { kind, receiptId: receipt.id },
});
return apiSuccess({
id: receipt.id,
receipt: serialiseReceipt(receipt),
});
});
interface SerialisedReceipt {
id: string;
userId: string;
kind: string;
signedAt: string;
revokedAt: string | null;
createdAt: string;
}
/**
* Public projection of a `ConsentReceipt`. We deliberately strip
* `artefact` from the API response — the artefact is large, opaque,
* and only useful at audit time (read directly from the DB by an
* operator). Echoing it on every grant would balloon the payload and
* leak the signed token over network paths it doesn't need to cross.
*/
export function serialiseReceipt(receipt: {
id: string;
userId: string;
kind: string;
signedAt: Date;
revokedAt: Date | null;
createdAt: Date;
}): SerialisedReceipt {
return {
id: receipt.id,
userId: receipt.userId,
kind: receipt.kind,
signedAt: receipt.signedAt.toISOString(),
revokedAt: receipt.revokedAt?.toISOString() ?? null,
createdAt: receipt.createdAt.toISOString(),
};
}
+33 -1
View File
@@ -49,6 +49,7 @@ import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { expandTodayIntakes } from "@/lib/medication-schedule";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -349,6 +350,35 @@ async function projectAndReadTodaysIntakes(
})),
skipDuplicates: true,
});
// v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4.
// Mirrors the intake-route call site: after a bulk projection
// backfill, fire one rollup recompute per distinct
// `(medication_id, dayKey)` so the rollup row reflects the new
// `scheduled` count before the next read. Best-effort: errors
// stay caught inside the helper so a populator hiccup doesn't
// surface as a 5xx on the dashboard summary.
const seenDayKeys = new Set<string>();
const recomputeJobs: Array<Promise<void>> = [];
for (const m of missing) {
const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`;
if (seenDayKeys.has(key)) continue;
seenDayKeys.add(key);
recomputeJobs.push(
recomputeMedicationComplianceForEvent({
userId,
medicationId: m.medicationId,
scheduledFor: m.scheduledFor,
tz: userTz,
}),
);
}
// Defence in depth (specialist H1): the helper swallows internally
// today, but any future refactor that lets a throw escape would
// turn the dashboard summary into a 5xx. `allSettled` keeps the
// best-effort contract explicit at the call site so the request
// stays 200-OK regardless of helper behaviour.
await Promise.allSettled(recomputeJobs);
}
}
@@ -419,6 +449,7 @@ async function buildDashboardSummary(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${sevenDaysAgo}
AND m."deleted_at" IS NULL
ORDER BY m."type", m."measured_at" DESC
`,
),
@@ -440,7 +471,7 @@ async function buildDashboardSummary(
time("allTime", () =>
prisma.measurement.groupBy({
by: ["type"],
where: { userId, type: { in: measurementTypes } },
where: { userId, type: { in: measurementTypes }, deletedAt: null },
_count: { _all: true },
_max: { measuredAt: true },
}),
@@ -485,6 +516,7 @@ async function buildDashboardSummary(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."measured_at" >= ${streakWindowStart}
AND m."deleted_at" IS NULL
`,
),
]);
+5 -5
View File
@@ -31,17 +31,17 @@ vi.mock("@/lib/logging/context", () => ({
// assert that the import path now folds the persistent rollup tier
// for each touched (type, day). Mood rollup mock stays no-op because
// the existing v1.4.39 W-MOOD hook already covers the moodlog branch.
vi.mock("@/lib/measurements/rollups", async () => {
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/measurements/rollups")
>("@/lib/measurements/rollups");
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
};
});
vi.mock("@/lib/mood/rollups", () => ({
vi.mock("@/lib/rollups/mood-rollups", () => ({
recomputeUserMoodRollups: vi.fn().mockResolvedValue(undefined),
}));
@@ -49,7 +49,7 @@ import { NextRequest } from "next/server";
import { POST } from "../route";
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
beforeEach(() => {
vi.resetAllMocks();
+2 -2
View File
@@ -17,11 +17,11 @@ import {
measurementTypeEnum,
glucoseContextEnum,
} from "@/lib/validations/measurement";
import { recomputeUserMoodRollups } from "@/lib/mood/rollups";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
collapseToTypeDayKeys,
recomputeBucketsForMeasurement,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import type { MeasurementType } from "@/generated/prisma/client";
// Derived from canonical enum so round-trip export → import covers every
+1 -1
View File
@@ -12,7 +12,7 @@ import { hashToken } from "@/lib/auth/hmac";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { externalIntakeSchema } from "@/lib/validations/medication";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { NextRequest, NextResponse } from "next/server";
/**
+1
View File
@@ -77,6 +77,7 @@ export const GET = apiHandler(async () => {
in: ["WEIGHT", "BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA", "PULSE"],
},
measuredAt: { gte: ninetyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
@@ -20,15 +20,30 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
appSettings: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
$queryRaw: vi.fn(),
$queryRawUnsafe: vi.fn(),
},
}));
// v1.4.40 — stub the mood-rollup warm-up so test runs don't fire the
// real recompute against the mocked Prisma. The route fires the
// warm-up as fire-and-forget; the return value is irrelevant.
vi.mock("@/lib/rollups/mood-rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
"@/lib/rollups/mood-rollups",
);
return {
...actual,
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
};
});
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
@@ -98,6 +113,9 @@ beforeEach(() => {
dateOfBirth: new Date("1985-01-01"),
});
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
@@ -267,6 +285,109 @@ describe("GET /api/insights/comprehensive — envelope shape", () => {
expect(body.data.bpClassification?.category).toBe("Normal");
});
it("consumes the mood-rollup tier and skips the raw findMany when DAY rows exist", async () => {
// v1.4.40 W-INSIGHTS — rollup-tier read swap parity. Three DAY rows
// populated → the route reads them directly and never touches the
// raw `moodEntry.findMany`. `moodSummary` is computed over per-day
// mean DataPoints, matching the rollup-tier convention shipped by
// `/api/mood/analytics` post-v1.4.39.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue({
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
});
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([
{
userId: "user-comp-1",
granularity: "DAY",
bucketStart: new Date("2026-05-08T00:00:00.000Z"),
count: 1,
mean: 4,
minScore: 4,
maxScore: 4,
sd: null,
computedAt: new Date(),
},
{
userId: "user-comp-1",
granularity: "DAY",
bucketStart: new Date("2026-05-09T00:00:00.000Z"),
count: 2,
mean: 4.5,
minScore: 4,
maxScore: 5,
sd: 0.5,
computedAt: new Date(),
},
]);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
// Raw findMany must not fire on the rollup-tier fast path.
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
const body = (await res.json()) as {
data: { moodSummary: { count: number; mean: number | null } | null };
};
// `summarize()` is called over two DataPoints (one per day).
expect(body.data.moodSummary).not.toBeNull();
expect(body.data.moodSummary?.count).toBe(2);
// Mean of (4 + 4.5) / 2 = 4.25
expect(body.data.moodSummary?.mean).toBeCloseTo(4.25, 2);
});
it("falls back to the bounded live walk when the rollup tier is empty but raw mood entries exist", async () => {
// v1.4.40 W-INSIGHTS — coverage-fallback parity. Legacy account
// with raw entries but no rollup coverage yet. The route runs the
// legacy bounded walk once; the warm-up helper (stubbed) is
// supposed to fire fire-and-forget so the next request lands on
// the rollup tier.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
(
buildComprehensiveAggregate as ReturnType<typeof vi.fn>
).mockResolvedValue({
summaries: {},
bpRawRows: { sys: [], dia: [] },
dailyByType: {},
firstMeasurementAt: new Date(),
totalMeasurements: 0,
});
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{
date: "2026-05-08",
score: 4,
moodLoggedAt: new Date("2026-05-08T12:00:00.000Z"),
},
{
date: "2026-05-09",
score: 5,
moodLoggedAt: new Date("2026-05-09T12:00:00.000Z"),
},
]);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
const body = (await res.json()) as {
data: { moodSummary: { count: number } | null };
};
expect(body.data.moodSummary).not.toBeNull();
expect(body.data.moodSummary?.count).toBe(2);
});
it("derives weight × BP scatter from daily-bucketed series", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// Five day-aligned pairs (n >= 5 unlocks pearsonCorrelation).
+70 -32
View File
@@ -22,6 +22,10 @@ import { annotate } from "@/lib/logging/context";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { buildComprehensiveAggregate } from "@/lib/insights/comprehensive-aggregator";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
export const dynamic = "force-dynamic";
@@ -82,39 +86,73 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
// buckets for the correlation pairings.
const aggregate = await buildComprehensiveAggregate(userId);
// Mood entries are user-recorded; the table is small and the
// `moodSummary` field requires raw scores (`summarize()` over the
// raw score series). The findMany stays.
const moodEntries = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
orderBy: { moodLoggedAt: "asc" },
select: { date: true, score: true, moodLoggedAt: true },
});
// Aggregate mood to daily averages (drives the mood × metric
// correlations). Bucket key matches the legacy code's `date` column
// semantics (YYYY-MM-DD anchored to the user's tz at write time).
const moodByDay = new Map<string, { sum: number; count: number }>();
for (const entry of moodEntries) {
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
current.sum += entry.score;
current.count += 1;
moodByDay.set(entry.date, current);
}
const dailyMoodEntries = Array.from(moodByDay.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, stats]) => ({
day,
value: Math.round((stats.sum / stats.count) * 100) / 100,
// v1.4.40 — swap the 90-day unbounded `prisma.moodEntry.findMany`
// for the persistent mood-rollup DAY tier (audit Critical Finding
// #2). The two consumers downstream — `dailyMoodEntries` for the
// mood × metric correlations and `moodSummary` for the dashboard
// mood block — read at a per-day resolution, which is precisely the
// shape the rollup writer emits.
//
// Coverage-fallback: when the user has mood entries but zero rollup
// rows yet (legacy account before the boot-time backfill has caught
// up), fall back to a bounded 90-day raw walk once and fire the
// warm-up so the next request lands on the rollup tier. Same posture
// as `/api/mood/analytics`.
//
// The bucket key shape change is documented in the route-parity test
// for `/api/mood/analytics`: rollup `bucketStart` is UTC-anchored
// YYYY-MM-DD; the legacy `MoodEntry.date` column is TZ-anchored
// (write-time). For Berlin tenants whose mood log timestamps don't
// straddle the UTC boundary the two labels agree on every realistic
// entry — the v1.5 per-user-tz bucketing closes the residual DST
// edge.
void ensureUserMoodRollupsFresh(userId);
const moodRollupDayRows = await readMoodDayRollups(userId, ninetyDaysAgo);
let dailyMoodEntries: Array<{ day: string; value: number }>;
let moodDataPoints: DataPoint[];
let moodEntryCount: number;
if (moodRollupDayRows.length > 0) {
dailyMoodEntries = moodRollupDayRows.map((r) => ({
day: r.bucketStart.toISOString().slice(0, 10),
value: Math.round(r.mean * 100) / 100,
}));
moodDataPoints = moodRollupDayRows.map((r) => ({
date: r.bucketStart,
value: r.mean,
}));
moodEntryCount = moodRollupDayRows.reduce((s, r) => s + r.count, 0);
} else {
// Coverage-fallback: legacy account with mood entries but no
// rollup coverage yet. Bounded by the 90-day window so the walk is
// capped even when the rollup miss happens.
const moodEntries = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: ninetyDaysAgo } },
orderBy: { moodLoggedAt: "asc" },
select: { date: true, score: true, moodLoggedAt: true },
});
const moodByDay = new Map<string, { sum: number; count: number }>();
for (const entry of moodEntries) {
const current = moodByDay.get(entry.date) ?? { sum: 0, count: 0 };
current.sum += entry.score;
current.count += 1;
moodByDay.set(entry.date, current);
}
dailyMoodEntries = Array.from(moodByDay.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([day, stats]) => ({
day,
value: Math.round((stats.sum / stats.count) * 100) / 100,
}));
// Feed per-day means into `summarize()` so the live-fallback path
// shares the rollup-tier semantic (one DataPoint per day, the
// daily mean) — mirrors the v1.4.39 mood/analytics QA UX-H1 fix.
moodDataPoints = dailyMoodEntries.map((e) => ({
date: new Date(`${e.day}T12:00:00.000Z`),
value: e.value,
}));
moodEntryCount = moodEntries.length;
}
// moodSummary uses the same `summarize()` helper over raw scores —
// the table is bounded by user-recorded entries so the in-JS path
// is safe.
const moodDataPoints: DataPoint[] = moodEntries.map((e) => ({
date: e.moodLoggedAt,
value: e.score,
}));
const moodSummary =
moodDataPoints.length > 0 ? summarize(moodDataPoints) : null;
@@ -369,7 +407,7 @@ export async function buildComprehensiveResponse(user: AuthedUser) {
action: { name: "insights.comprehensive" },
meta: {
totalMeasurements: aggregate.totalMeasurements,
moodEntries: moodEntries.length,
moodEntries: moodEntryCount,
medications: medications.length,
},
});
+1 -1
View File
@@ -134,7 +134,7 @@ async function buildComparisonSnapshotForUser(
const rows = await Promise.all(
types.map(async (type) => {
const measurements = await prisma.measurement.findMany({
where: { userId, type },
where: { userId, type, deletedAt: null },
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
});
@@ -0,0 +1,280 @@
/**
* v1.4.40 W-INSIGHTS — `/api/insights/targets` mood-rollup tier swap.
*
* Audit Critical Finding #2 (`.planning/round-v1439-arch-qa-infra-db.md`)
* flagged the unbounded `prisma.moodEntry.findMany({ where: { userId } })`
* at the bottom of the route as one of the six unbounded mood walks
* that paint the Insights surface 12 s cold on a power-user account.
* This file pins:
*
* 1. **The mood-rollup DAY tier replaces the raw findMany when
* populated.** Mood targets (`MOOD_SCORE`, `MOOD_STABILITY`)
* compute their `current` / `average30` / `consistency7d` from
* the rollup row set; `prisma.moodEntry.findMany` is never
* called on the fast path. A single `findFirst` reads the
* latest score (one bounded row).
*
* 2. **The coverage-fallback runs the live walk once when the
* rollup tier is empty but raw entries exist** (legacy account
* before the boot-time backfill has caught up). Bounded by the
* trailing 30-day window so the fallback never repeats the
* legacy unbounded scan.
*
* 3. **The `findMany distinct(["type"])` "latest ever per type"
* query carries a `measuredAt: { gte: oneYearAgo }` floor**
* (audit High finding 3). Without the floor Postgres still has
* to sort the full 347 k-row tenant before applying DISTINCT ON
* because Prisma's `distinct` does not compile to PG's
* `DISTINCT ON`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
measurement: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn(), findFirst: vi.fn() },
moodEntryRollup: { findMany: vi.fn(), findFirst: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
appSettings: { findUnique: vi.fn() },
$queryRaw: vi.fn(),
$queryRawUnsafe: vi.fn(),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
// Stub the warm-up so test runs don't fire the real
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-forget
// on the route so its return value is irrelevant for parity.
vi.mock("@/lib/rollups/mood-rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
"@/lib/rollups/mood-rollups",
);
return {
...actual,
ensureUserMoodRollupsFresh: vi.fn().mockResolvedValue({ recomputed: false }),
};
});
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_USER = {
id: "user-targets-1",
username: "marc",
role: "USER" as const,
timezone: "Europe/Berlin",
heightCm: 180,
dateOfBirth: new Date("1985-01-01"),
locale: "en",
};
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: SESSION_USER as never,
};
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeReq(): NextRequest {
return new NextRequest("http://localhost/api/insights/targets");
}
interface TargetsBody {
data: {
targets: Array<{
type: string;
current: number | null;
average30: number | null;
daysInRange7d: number;
daysLogged7d: number;
insufficientData: boolean;
}>;
};
}
beforeEach(() => {
vi.resetAllMocks();
__resetAllCachesForTests();
vi.mocked(getSession).mockResolvedValue(SESSION_OK);
(prisma.user.findUnique as ReturnType<typeof vi.fn>).mockResolvedValue({
heightCm: 180,
dateOfBirth: new Date("1985-01-01"),
gender: "MALE",
glucoseUnit: "mg/dL",
thresholdsJson: null,
});
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
(prisma.medication.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
(
prisma.medicationIntakeEvent.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
});
describe("GET /api/insights/targets — mood-rollup tier swap", () => {
it("returns a mood target driven by the rollup DAY tier without touching the raw findMany", async () => {
// Five DAY-rollup rows over the trailing 30 days. The route reads
// them directly to drive `MOOD_SCORE.current/average30/consistency`
// — no raw `moodEntry.findMany` on the fast path.
const today = new Date();
const day = (offset: number) => {
const d = new Date(today);
d.setUTCHours(0, 0, 0, 0);
d.setUTCDate(d.getUTCDate() - offset);
return d;
};
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(0), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(1), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(2), count: 1, mean: 5, minScore: 5, maxScore: 5, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(3), count: 1, mean: 4, minScore: 4, maxScore: 4, sd: null, computedAt: new Date() },
{ userId: SESSION_USER.id, granularity: "DAY", bucketStart: day(4), count: 1, mean: 4.5, minScore: 4, maxScore: 5, sd: 0.5, computedAt: new Date() },
]);
// Latest score read — one bounded row.
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
score: 4,
moodLoggedAt: day(0),
});
const res = await callGet(makeReq());
expect(res.status).toBe(200);
// The unbounded raw `moodEntry.findMany` must not fire on the
// rollup-tier fast path.
expect(prisma.moodEntry.findMany).not.toHaveBeenCalled();
const body = (await res.json()) as TargetsBody;
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
expect(moodTarget).toBeDefined();
expect(moodTarget?.current).toBe(4);
// Average over 5 daily means: (4+4+5+4+4.5)/5 = 4.3
expect(moodTarget?.average30).toBeCloseTo(4.3, 1);
});
it("falls back to the bounded raw walk when the rollup tier is empty but raw mood entries exist", async () => {
// Rollup empty → coverage-fallback fires the legacy live walk
// once. The fallback is bounded by the 30-day window — not the
// legacy unbounded scan over every mood the user ever wrote.
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
const today = new Date();
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
score: 4,
moodLoggedAt: today,
});
(prisma.moodEntry.findMany as ReturnType<typeof vi.fn>).mockResolvedValue([
{ score: 4, moodLoggedAt: new Date(today.getTime() - 2 * 86_400_000) },
{ score: 5, moodLoggedAt: new Date(today.getTime() - 1 * 86_400_000) },
{ score: 4, moodLoggedAt: today },
]);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
// Live fallback fired exactly once. The fallback `findMany` call
// must carry a 30-day `moodLoggedAt: { gte: ... }` floor — i.e. a
// `where` clause with a timestamp filter, not the legacy
// unbounded `where: { userId }` shape.
expect(prisma.moodEntry.findMany).toHaveBeenCalledTimes(1);
const fallbackCall = (prisma.moodEntry.findMany as ReturnType<typeof vi.fn>)
.mock.calls[0][0];
expect(fallbackCall.where).toHaveProperty("moodLoggedAt");
expect(fallbackCall.where.moodLoggedAt).toHaveProperty("gte");
const body = (await res.json()) as TargetsBody;
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
expect(moodTarget).toBeDefined();
expect(moodTarget?.current).toBe(4);
});
it("emits no mood target when neither rollup nor raw entries exist", async () => {
(
prisma.moodEntryRollup.findMany as ReturnType<typeof vi.fn>
).mockResolvedValue([]);
(prisma.moodEntry.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue(
null,
);
const res = await callGet(makeReq());
expect(res.status).toBe(200);
const body = (await res.json()) as TargetsBody;
const moodTarget = body.data.targets.find((t) => t.type === "MOOD_SCORE");
expect(moodTarget).toBeUndefined();
});
it("floors the `findMany distinct` latest-ever-per-type read at one year", async () => {
// Audit High finding 3: the `findMany({orderBy: measuredAt desc,
// distinct: ["type"]})` for "absolute latest measurement per type"
// used to run WITHOUT a `measuredAt >=` floor. On a 347 k-row
// tenant Postgres still has to sort the full set before applying
// DISTINCT ON because Prisma's `distinct` does not compile to PG's
// `DISTINCT ON` — it dedups in the driver. v1.4.40 adds a
// 365-day floor so the planner-side walk is bounded.
(prisma.measurement.findMany as ReturnType<typeof vi.fn>).mockResolvedValue(
[],
);
await callGet(makeReq());
// Find the `distinct: ["type"]` call.
const calls = (
prisma.measurement.findMany as ReturnType<typeof vi.fn>
).mock.calls.map((c) => c[0]);
const distinctCall = calls.find(
(c) =>
c &&
Array.isArray(c.distinct) &&
c.distinct[0] === "type" &&
c.orderBy?.measuredAt === "desc",
);
expect(distinctCall).toBeDefined();
// The `measuredAt >= oneYearAgo` floor must be present.
expect(distinctCall?.where?.measuredAt).toBeDefined();
expect(distinctCall?.where?.measuredAt?.gte).toBeInstanceOf(Date);
const flooredAt = distinctCall?.where?.measuredAt?.gte as Date;
const oneYearMs = 365 * 24 * 60 * 60 * 1000;
const ageMs = Date.now() - flooredAt.getTime();
// Floor is somewhere around one year ago — give a ±1-hour window
// for clock drift between the route's `Date.now()` and the test
// assertion.
expect(ageMs).toBeGreaterThan(oneYearMs - 3_600_000);
expect(ageMs).toBeLessThan(oneYearMs + 3_600_000);
});
});
+130 -35
View File
@@ -33,6 +33,10 @@ import {
} from "@/lib/analytics/effective-range";
import { getBodyFatTargetRange } from "@/lib/analytics/value-bands";
import { thresholdMetricForContext, resolveGlucoseUnit } from "@/lib/glucose";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/rollups/mood-rollups";
export const dynamic = "force-dynamic";
@@ -174,22 +178,41 @@ async function buildTargetsResponse(user: AuthedUser) {
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// Fetch all measurements in the last 30 days + the latest for each type
// Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows
// must not contribute to tile averages / latest values once iOS sync
// starts emitting deletions.
const recentMeasurements = await prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: thirtyDaysAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
select: { type: true, value: true, measuredAt: true },
});
// Also get the absolute latest measurement per type (even if older than 30 days).
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
// (Postgres) emulated via a windowed orderBy + take-1 per type would still be
// N round-trips, so we sort once on the wide query and pick per-type below.
// Absolute latest measurement per type within a one-year floor.
//
// Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the
// driver dedups after pulling the rows, so an unbounded scan on a
// 347 k-row tenant pulls every measurement back to Node only to drop
// all but seven (audit Critical Finding #2, v1.4.40). The 365-day
// floor caps the planner-side walk to the existing
// `(user_id, type, measured_at)` index range; the trade-off is that a
// user who hasn't measured a metric in over a year sees the tile's
// `current` value as null instead of pulling a stale year-old reading
// forward — which is the correct behaviour for the consistency strip
// (the strip already gates on "fewer than 3 readings in 30 days" →
// insufficientData).
const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000);
const latestEverByType = await prisma.measurement.findMany({
where: { userId, type: { in: types } },
where: {
userId,
type: { in: types },
measuredAt: { gte: oneYearAgo },
deletedAt: null,
},
orderBy: { measuredAt: "desc" },
distinct: ["type"],
select: { type: true, value: true },
@@ -980,41 +1003,112 @@ async function buildTargetsResponse(user: AuthedUser) {
}
// 9. Mood targets (if mood data exists)
const moodEntries = await prisma.moodEntry.findMany({
//
// v1.4.40 \u2014 swap the unbounded `prisma.moodEntry.findMany({ where: {
// userId } })` for the persistent mood-rollup DAY tier (audit Critical
// Finding #2). The tile reads:
// - per-day mean over the trailing 30 days (avg30, trend, stability)
// - the consistency strip (in/near/out band per day)
// - the latest score for the "current" reading + classification
//
// Rollup row = `{bucketStart, mean, count, minScore, maxScore}` per
// calendar day. Per-day mean replaces the per-entry score series the
// legacy path passed to `rollupConsistency` \u2014 the helper buckets by
// day-key anyway, so feeding one row per day with the pre-computed
// mean is mathematically identical to feeding N raw entries that
// collapse to the same mean.
//
// The "latest score" needs a single raw entry (the newest one), so we
// pull it with `take: 1` \u2014 bounded by the index, never an unbounded
// scan. The "\u2265 3 entries threshold" gate uses the rollup count sum so
// the cold-start branch stays consistent with the legacy semantics
// (which counted raw entries, not raw days).
//
// Coverage-fallback: when the user has mood entries but zero rollup
// rows yet (legacy account before the boot-time backfill caught up)
// we run the legacy live walk once and fire the warm-up so the next
// request lands on the rollup tier. Same posture as
// `/api/mood/analytics`.
void ensureUserMoodRollupsFresh(userId);
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// Five-year window mirrors `/api/mood/analytics` so existing rollup
// coverage is consulted; the trailing 30-day filter happens in JS
// because the consistency strip already walks per-day buckets.
const moodRollupWindowMs = 5 * 365 * 24 * 60 * 60 * 1000;
const moodSince = new Date(Date.now() - moodRollupWindowMs);
const moodRollups = await readMoodDayRollups(userId, moodSince);
const latestMoodEntry = await prisma.moodEntry.findFirst({
where: { userId },
orderBy: { moodLoggedAt: "desc" },
select: { score: true, moodLoggedAt: true },
});
if (moodEntries.length >= 3) {
const thirtyDaysAgoMood = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const recentMood = moodEntries.filter(
(entry) => entry.moodLoggedAt >= thirtyDaysAgoMood,
);
const latestMoodScore = moodEntries[0]?.score ?? null;
// Build a per-day event list for the consistency strip + 30-day stats.
// Use the rollup tier when populated; fall back to a bounded raw walk
// when the user has mood entries but zero rollup coverage yet.
let moodDailyEvents: Array<{ measuredAt: Date; value: number }>;
let moodEntryCount: number;
let moodSumScores: number;
let moodVarianceN: number;
let moodSumSquares: number;
if (moodRollups.length > 0) {
moodEntryCount = moodRollups.reduce((s, r) => s + r.count, 0);
moodDailyEvents = moodRollups
.filter((r) => r.bucketStart >= thirtyDaysAgoMood)
.map((r) => ({ measuredAt: r.bucketStart, value: r.mean }));
moodSumScores = moodDailyEvents.reduce((s, e) => s + e.value, 0);
moodVarianceN = moodDailyEvents.length;
moodSumSquares = moodDailyEvents.reduce((s, e) => s + e.value * e.value, 0);
} else if (latestMoodEntry !== null) {
// Coverage-fallback: rollups empty but raw entries exist. Pull the
// trailing 30-day window only (instead of every entry ever) \u2014 that's
// the bound the legacy code was missing.
const recentRawMood = await prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: thirtyDaysAgoMood } },
orderBy: { moodLoggedAt: "asc" },
select: { score: true, moodLoggedAt: true },
});
moodEntryCount = recentRawMood.length;
moodDailyEvents = recentRawMood.map((e) => ({
measuredAt: e.moodLoggedAt,
value: e.score,
}));
moodSumScores = recentRawMood.reduce((s, e) => s + e.score, 0);
moodVarianceN = recentRawMood.length;
moodSumSquares = recentRawMood.reduce((s, e) => s + e.score * e.score, 0);
} else {
moodEntryCount = 0;
moodDailyEvents = [];
moodSumScores = 0;
moodVarianceN = 0;
moodSumSquares = 0;
}
if (moodEntryCount >= 3) {
const latestMoodScore = latestMoodEntry?.score ?? null;
const moodAvg30 =
recentMood.length > 0
moodDailyEvents.length > 0
? Math.round(
(recentMood.reduce((sum, e) => sum + e.score, 0) /
recentMood.length) *
10,
(moodSumScores / moodDailyEvents.length) * 10,
) / 10
: null;
// Mood trend: compare first half to second half of last 30 days
// Mood trend: compare first half to second half of last 30 days.
// The rollup tier carries one DataPoint per day (the daily mean),
// matching the legacy comparison semantics on single-entry days.
let moodTrend: "up" | "down" | "stable" | null = null;
if (recentMood.length >= 4) {
const sorted = [...recentMood].sort(
(a, b) => a.moodLoggedAt.getTime() - b.moodLoggedAt.getTime(),
if (moodDailyEvents.length >= 4) {
const sorted = [...moodDailyEvents].sort(
(a, b) => a.measuredAt.getTime() - b.measuredAt.getTime(),
);
const mid = Math.floor(sorted.length / 2);
const firstHalf = sorted.slice(0, mid);
const secondHalf = sorted.slice(mid);
const avgFirst =
firstHalf.reduce((s, e) => s + e.score, 0) / firstHalf.length;
firstHalf.reduce((s, e) => s + e.value, 0) / firstHalf.length;
const avgSecond =
secondHalf.reduce((s, e) => s + e.score, 0) / secondHalf.length;
secondHalf.reduce((s, e) => s + e.value, 0) / secondHalf.length;
const diff = avgSecond - avgFirst;
if (diff > 0.2) moodTrend = "up";
else if (diff < -0.2) moodTrend = "down";
@@ -1031,12 +1125,8 @@ async function buildTargetsResponse(user: AuthedUser) {
: null;
const moodRange = { min: 3.5, max: 5 };
const moodEventsForConsistency = recentMood.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
const moodConsistency = rollupConsistency(
moodEventsForConsistency,
moodDailyEvents,
makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }),
);
targets.push({
@@ -1052,13 +1142,18 @@ async function buildTargetsResponse(user: AuthedUser) {
...moodConsistency,
});
// Mood stability: standard deviation of recent scores (lower = more stable)
if (recentMood.length >= 5) {
const mean = moodAvg30 ?? latestMoodScore ?? 3;
const variance =
recentMood.reduce((sum, e) => sum + (e.score - mean) ** 2, 0) /
recentMood.length;
const stdDev = Math.round(Math.sqrt(variance) * 100) / 100;
// Mood stability: standard deviation of the recent series. The
// rollup tier surfaces one mean per day, which is the right
// resolution for "is the user's mood stable across days" \u2014 a power
// user logging multiple times per day with a wide intraday spread
// still gets a "stable" badge if the daily mean barely moves. The
// legacy path conflated intraday variance with day-to-day stability
// by passing per-entry scores; the rollup-tier semantic is closer
// to what the UI label suggests.
if (moodVarianceN >= 5) {
const seriesMean = moodSumScores / moodVarianceN;
const variance = moodSumSquares / moodVarianceN - seriesMean * seriesMean;
const stdDev = Math.round(Math.sqrt(Math.max(0, variance)) * 100) / 100;
const stabilityClassification =
stdDev <= 0.5
@@ -1103,7 +1198,7 @@ async function buildTargetsResponse(user: AuthedUser) {
};
const thirtyDaysAgoGlucose = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const glucoseRows = await prisma.measurement.findMany({
where: { userId, type: "BLOOD_GLUCOSE" },
where: { userId, type: "BLOOD_GLUCOSE", deletedAt: null },
orderBy: { measuredAt: "desc" },
select: { value: true, measuredAt: true, glucoseContext: true },
});
@@ -8,7 +8,7 @@ import { moodLogWebhookPayloadSchema } from "@/lib/validations/moodlog";
import { readMoodLogSecret } from "@/lib/moodlog-secret";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
export const dynamic = "force-dynamic";
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from "@/lib/api-response";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { recomputeBucketsForMeasurement } from "@/lib/measurements/rollups";
import { recomputeBucketsForMeasurement } from "@/lib/rollups/measurement-rollups";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
@@ -30,10 +30,10 @@ vi.mock("@/lib/db", () => ({
// v1.4.39.2 — the rollup branch's coverage-mismatch fallback may call
// `recomputeUserRollups` inline. Mock it so tests can assert whether
// the inline fold fired without exercising the real populator.
vi.mock("@/lib/measurements/rollups", async () => {
vi.mock("@/lib/rollups/measurement-rollups", async () => {
const actual = await vi.importActual<
typeof import("@/lib/measurements/rollups")
>("@/lib/measurements/rollups");
typeof import("@/lib/rollups/measurement-rollups")
>("@/lib/rollups/measurement-rollups");
return {
...actual,
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
@@ -61,7 +61,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { recomputeUserRollups } from "@/lib/measurements/rollups";
import { recomputeUserRollups } from "@/lib/rollups/measurement-rollups";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
+1 -1
View File
@@ -41,7 +41,7 @@ import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import { Prisma, type MeasurementType } from "@/generated/prisma/client";
// v1.4.25 W16c — historical-backfill threshold for PR push
@@ -35,7 +35,7 @@ import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
const MAX_BATCH_ENTRIES = 500;
+10 -1
View File
@@ -30,7 +30,7 @@ import {
recomputeBucketsForMeasurement,
collapseToTypeDayKeys,
recomputeUserRollups,
} from "@/lib/measurements/rollups";
} from "@/lib/rollups/measurement-rollups";
import { NextRequest } from "next/server";
import type {
MeasurementType,
@@ -64,6 +64,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
const where = {
userId: user.id,
// v1.4.40 W-DELETED — soft-deleted readings are invisible to every
// list / count / aggregate read. The dedicated `sync/state` route
// keeps surfacing them via its own filter so iOS can reconcile
// tombstones; every other consumer (list view, analytics fan-out,
// dashboard read paths) must never include them.
deletedAt: null,
...(type && { type: type as MeasurementType }),
...(from || to
? {
@@ -99,6 +105,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
userId: user.id,
type: type as MeasurementType,
measuredAt: { gte: dayStart, lt: dayEnd },
deletedAt: null,
},
orderBy: { measuredAt: sortDir },
// v1.4.38 — the 1000-row cap now lives on the validator (refine
@@ -347,6 +354,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
AND m."type" = ${type}::measurement_type
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
`;
const liveDays = Number(liveDayCountRows[0]?.days ?? BigInt(0));
if (liveDays > rollupRows.length) {
@@ -484,6 +492,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
WHERE m."user_id" = ${user.id}
AND m."measured_at" >= ${from}
AND m."measured_at" <= ${to}
AND m."deleted_at" IS NULL
${type ? Prisma.sql`AND m."type" = ${type}::measurement_type` : Prisma.empty}
GROUP BY m."type", bucket_start
ORDER BY bucket_start ASC
+8 -1
View File
@@ -84,6 +84,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
userId: user.id,
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
@@ -93,6 +94,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
userId: user.id,
type: "BLOOD_PRESSURE_DIA",
measuredAt: { gte: since },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
@@ -120,7 +122,12 @@ export const GET = apiHandler(async (request: NextRequest) => {
} else {
const type = KIND_TO_TYPE[kind];
const rows = await prisma.measurement.findMany({
where: { userId: user.id, type, measuredAt: { gte: since } },
where: {
userId: user.id,
type,
measuredAt: { gte: since },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { id: true, value: true, measuredAt: true },
});
@@ -10,7 +10,7 @@ import {
} from "@/lib/api-response";
import { updateIntakeEventSchema } from "@/lib/validations/medication";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string; eventId: string }> };
@@ -11,7 +11,7 @@ import {
import {
recomputeMedicationComplianceForDay,
dayKeyForScheduledFor,
} from "@/lib/medications/compliance-rollups";
} from "@/lib/rollups/medication-compliance-rollups";
import { NextRequest } from "next/server";
import { z } from "zod/v4";
+1 -1
View File
@@ -16,7 +16,7 @@ import { withIdempotency } from "@/lib/idempotency";
import { consumeOneDose } from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { invalidateUserMedications } from "@/lib/cache/invalidate";
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -1,105 +0,0 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
const querySchema = z.object({
from: z.iso
.datetime({ offset: true })
.transform((value) => new Date(value))
.optional(),
to: z.iso
.datetime({ offset: true })
.transform((value) => new Date(value))
.optional(),
});
function toBerlinDayKey(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
interface DailySummary {
date: string;
total: number;
medications: Record<string, number>;
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { from, to } = parsed.data;
const events = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
skipped: false,
takenAt: {
not: null,
...(from && { gte: from }),
...(to && { lte: to }),
},
},
select: {
takenAt: true,
medication: {
select: {
name: true,
},
},
},
orderBy: { takenAt: "asc" },
});
const medicationNames = new Set<string>();
const byDay = new Map<string, DailySummary>();
for (const event of events) {
if (!event.takenAt) continue;
const medicationName = event.medication.name;
const dayKey = toBerlinDayKey(event.takenAt);
medicationNames.add(medicationName);
const existing = byDay.get(dayKey) ?? {
date: dayKey,
total: 0,
medications: {},
};
existing.total += 1;
existing.medications[medicationName] =
(existing.medications[medicationName] ?? 0) + 1;
byDay.set(dayKey, existing);
}
const points = [...byDay.values()].sort((a, b) =>
a.date.localeCompare(b.date),
);
annotate({
action: { name: "medication.intake_summary" },
meta: {
point_count: points.length,
medication_count: medicationNames.size,
},
});
return apiSuccess({
points,
medications: [...medicationNames].sort((a, b) => a.localeCompare(b, "de")),
});
});
@@ -201,6 +201,15 @@ describe("GET /api/medications/intake", () => {
source: "REMINDER",
},
],
// v1.4.39 W-SERVER-FIX-2 — the createMany must ship
// `skipDuplicates: true` so a concurrent dashboard-summary hit
// can't race a duplicate `(userId, medicationId, scheduledFor,
// REMINDER)` row in between this route's existence probe and
// insert. The schema-level @@unique([userId, medicationId,
// scheduledFor, source]) is the structural backstop; this flag
// is the defense-in-depth that keeps the route returning 2xx
// when the constraint rejects the second mint.
skipDuplicates: true,
});
});
+1 -1
View File
@@ -44,7 +44,7 @@ import { invalidateUserMedications } from "@/lib/cache/invalidate";
import {
recomputeMedicationComplianceForDay,
dayKeyForScheduledFor,
} from "@/lib/medications/compliance-rollups";
} from "@/lib/rollups/medication-compliance-rollups";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
+34 -1
View File
@@ -32,7 +32,7 @@ import {
recomputeMedicationComplianceForEvent,
enqueueUserMedicationComplianceBackfill,
type ComplianceBucket as RollupComplianceBucket,
} from "@/lib/medications/compliance-rollups";
} from "@/lib/rollups/medication-compliance-rollups";
import { expandTodayIntakes } from "@/lib/medication-schedule";
const querySchema = z.object({
@@ -156,6 +156,39 @@ export const GET = apiHandler(async (request: NextRequest) => {
// structural backstop, this is the defense-in-depth.
skipDuplicates: true,
});
// v1.4.40 — close the compliance-rollup hook gap from v1.4.39.4.
// The bulk projection mints fresh `(medicationId, scheduledFor)`
// rows in PENDING state; without this hook the rollup for the
// affected `(user, medication, day)` tuples stays at its previous
// (pre-projection) `scheduled` count, which inflates the apparent
// compliance % until the user actually logs against the new
// row. Coalesce by `(medicationId, dayKey)` so we fire one
// recompute per distinct day-tuple instead of one per row.
// Best-effort: errors stay caught inside the helper so a
// populator hiccup doesn't surface as a 5xx on the intake
// request. Mirror call site in `dashboard/summary/route.ts`.
const seenDayKeys = new Set<string>();
const recomputeJobs: Array<Promise<void>> = [];
for (const m of missing) {
const key = `${m.medicationId}|${m.scheduledFor.toISOString().slice(0, 10)}`;
if (seenDayKeys.has(key)) continue;
seenDayKeys.add(key);
recomputeJobs.push(
recomputeMedicationComplianceForEvent({
userId: user.id,
medicationId: m.medicationId,
scheduledFor: m.scheduledFor,
tz: userTz,
}),
);
}
// Defence in depth (specialist H1): the helper swallows internally
// today, but any future refactor that lets a throw escape would
// turn the intake POST into a 5xx. `allSettled` keeps the
// best-effort contract explicit at the call site so the request
// stays 200-OK regardless of helper behaviour.
await Promise.allSettled(recomputeJobs);
}
const events = await prisma.medicationIntakeEvent.findMany({
@@ -1,164 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAdmin: vi.fn(async () => ({
user: { id: "u-admin" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((x: string) => x),
}));
vi.mock("@/lib/monitoring/glitchtip", () => ({
sendGlitchtipEvent: vi.fn(),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
interface ApiErrorEnvelope {
data: null;
error: string;
}
interface ApiSuccessEnvelope<T> {
data: T;
error: null;
}
const originalFetch = global.fetch;
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockReset();
vi.mocked(sendGlitchtipEvent).mockReset();
global.fetch = vi.fn() as never;
});
afterEach(() => {
global.fetch = originalFetch;
});
function emptyRequest(): Request {
return new Request("http://localhost/api/monitoring/glitchtip/test", {
method: "POST",
headers: { "content-length": "0" },
});
}
describe("POST /api/monitoring/glitchtip/test", () => {
it("happy path returns ok with statusCode", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
glitchtipDsn: "https://abc@glitch.example.com/1",
} as never);
vi.mocked(sendGlitchtipEvent).mockResolvedValueOnce({
ok: true,
method: "envelope",
status: 200,
});
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as ApiSuccessEnvelope<{
ok: boolean;
statusCode: number;
latencyMs: number;
}>;
expect(body.data.ok).toBe(true);
expect(body.data.statusCode).toBe(200);
expect(typeof body.data.latencyMs).toBe("number");
// CRITICAL guard against the `id: "default"` typo regression.
expect(prisma.appSettings.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "singleton" } }),
);
});
it("rejects HTTP DSN with 422", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
glitchtipDsn: "http://abc@glitch.example.com/1",
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope & {
meta: { errorCode: string };
};
expect(body.meta.errorCode).toBe("dsn_not_https");
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
});
it("rejects DSN pointing at a private host (SSRF guard)", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
glitchtipDsn: "https://abc@127.0.0.1/1",
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope & {
meta: { errorCode: string };
};
expect(body.meta.errorCode).toBe("dsn_host_not_public");
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
});
it("rate-limit denial returns 429 with no upstream call", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
});
it("returns 422 when DSN is not configured", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
glitchtipDsn: null,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/not configured/i);
expect(sendGlitchtipEvent).not.toHaveBeenCalled();
});
it("does not leak DSN/Bearer/sk- on upstream 401", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
glitchtipDsn: "https://abc@glitch.example.com/1",
} as never);
vi.mocked(sendGlitchtipEvent).mockRejectedValueOnce(
new Error(
"401 Unauthorized: Authorization: Bearer sk-secret from https://glitch.example.com/api/1/envelope/",
),
);
const response = await POST(emptyRequest() as never);
const text = await response.text();
expect(response.status).toBe(502);
expect(text).not.toMatch(/sk-/);
expect(text).not.toMatch(/glitch\.example\.com/);
});
});
@@ -1,121 +0,0 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { sendGlitchtipEvent } from "@/lib/monitoring/glitchtip";
import { isPublicUrl } from "@/lib/validations/notifications";
export const dynamic = "force-dynamic";
const TIMEOUT_MS = 5_000;
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAdmin();
annotate({ action: { name: "monitoring.glitchtip.test" } });
const rl = await checkRateLimit(`glitchtip-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { glitchtipDsn: true },
});
if (!settings?.glitchtipDsn) {
return apiError("Glitchtip DSN not configured", 422, {
errorCode: "not_configured",
});
}
// DSN is admin-set, but a compromised/social-engineered admin (or a stored
// settings record from a hostile import) could aim Glitchtip at internal
// hosts. Validate the host is publicly reachable and uses HTTPS before we
// hand the URL to the helper.
let dsnHost: string;
try {
const parsed = new URL(settings.glitchtipDsn);
if (parsed.protocol !== "https:") {
return apiError("Glitchtip DSN must use HTTPS", 422, {
errorCode: "dsn_not_https",
});
}
if (!isPublicUrl(`${parsed.protocol}//${parsed.host}`)) {
return apiError("Glitchtip DSN host is not publicly reachable", 422, {
errorCode: "dsn_host_not_public",
});
}
dsnHost = parsed.host;
} catch {
return apiError("Glitchtip DSN is malformed", 422, {
errorCode: "dsn_invalid",
});
}
const start = performance.now();
const send = sendGlitchtipEvent({
dsn: settings.glitchtipDsn,
input: {
environment: process.env.NODE_ENV ?? "production",
message: "HealthLog Glitchtip self-test",
level: "info",
// sourceTag rides through into the event tags. Adding "noise: true" lets
// operators silence these in their inbound Glitchtip rules so the test
// button doesn't pollute the issue inbox.
sourceTag: "self-test:noise",
},
});
const timeoutErr = new Error("timeout");
timeoutErr.name = "AbortError";
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(timeoutErr), TIMEOUT_MS),
);
try {
const result = await Promise.race([send, timeout]);
const latencyMs = Math.round(performance.now() - start);
if (!result.ok) {
annotate({
meta: {
glitchtip_test_status: result.status ?? null,
glitchtip_test_method: result.method ?? null,
glitchtip_test_latency_ms: latencyMs,
glitchtip_test_host: dsnHost,
},
});
return apiError("Glitchtip rejected the event", 502, {
errorCode: "upstream_error",
});
}
return apiSuccess({
ok: true,
statusCode: result.status ?? 200,
latencyMs,
});
} catch (e) {
const err = e as Error;
const isTimeout = err.name === "AbortError";
const code = isTimeout ? "timeout" : "connection_failed";
annotate({
meta: {
glitchtip_test_code: code,
glitchtip_test_host: dsnHost,
glitchtip_test_error: err.message.slice(0, 200),
},
});
return apiError(
isTimeout ? "Glitchtip request timed out" : "Glitchtip connection failed",
502,
{ errorCode: code },
);
}
});
@@ -1,135 +0,0 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAdmin: vi.fn(async () => ({
user: { id: "u-admin" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
appSettings: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(async () => ({ allowed: true })),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((x: string) => x),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
interface ApiErrorEnvelope {
data: null;
error: string;
}
interface ApiSuccessEnvelope<T> {
data: T;
error: null;
}
const originalFetch = global.fetch;
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.appSettings.findUnique).mockReset();
global.fetch = vi.fn() as never;
});
afterEach(() => {
global.fetch = originalFetch;
});
function emptyRequest(): Request {
return new Request("http://localhost/api/monitoring/umami/test", {
method: "POST",
headers: { "content-length": "0" },
});
}
describe("POST /api/monitoring/umami/test", () => {
it("happy path returns ok with hasMarker=true", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
umamiScriptUrl: "https://analytics.example.com/script.js",
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response("(function(){window.umami=function(){};})();", {
status: 200,
}),
);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(200);
const body = (await response.json()) as ApiSuccessEnvelope<{
ok: boolean;
statusCode: number;
hasMarker: boolean;
}>;
expect(body.data.ok).toBe(true);
expect(body.data.hasMarker).toBe(true);
expect(body.data.statusCode).toBe(200);
// CRITICAL guard: AppSettings is keyed on `id: "singleton"` everywhere
// else in the codebase. A typo here ("default") would silently make the
// route return 422 in prod even with valid settings.
expect(prisma.appSettings.findUnique).toHaveBeenCalledWith(
expect.objectContaining({ where: { id: "singleton" } }),
);
});
it("rate-limit denial returns 429 with no upstream call", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(429);
expect(global.fetch).not.toHaveBeenCalled();
});
it("returns 422 when URL not configured", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
umamiScriptUrl: null,
} as never);
const response = await POST(emptyRequest() as never);
expect(response.status).toBe(422);
const body = (await response.json()) as ApiErrorEnvelope;
expect(body.error).toMatch(/not configured/i);
expect(global.fetch).not.toHaveBeenCalled();
});
it("does not leak Bearer/sk- or upstream URL on upstream 401", async () => {
vi.mocked(prisma.appSettings.findUnique).mockResolvedValueOnce({
umamiScriptUrl: "https://analytics.example.com/script.js",
} as never);
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce(
new Response(
"Authorization: Bearer sk-secret echoed back from https://analytics.example.com/script.js",
{ status: 401 },
),
);
const response = await POST(emptyRequest() as never);
const text = await response.text();
expect(response.status).toBe(502);
expect(text).not.toMatch(/sk-/);
expect(text).not.toMatch(/analytics\.example\.com/);
});
});
-130
View File
@@ -1,130 +0,0 @@
import type { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess, apiError } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { isPublicUrl } from "@/lib/validations/notifications";
export const dynamic = "force-dynamic";
const TIMEOUT_MS = 5_000;
const MAX_BODY_BYTES = 256 * 1024;
function redact(text: string): string {
return text.replace(/https?:\/\/\S+/gi, "[url]");
}
export const POST = apiHandler(async (request: NextRequest) => {
void request;
const { user } = await requireAdmin();
annotate({ action: { name: "monitoring.umami.test" } });
const rl = await checkRateLimit(`umami-test:${user.id}`, 5, 60_000);
if (!rl.allowed) {
return apiError("Too many test requests", 429, {
errorCode: "rate_limited_self",
});
}
const settings = await prisma.appSettings.findUnique({
where: { id: "singleton" },
select: { umamiScriptUrl: true },
});
if (!settings?.umamiScriptUrl) {
return apiError("Umami script URL not configured", 422, {
errorCode: "not_configured",
});
}
const url = settings.umamiScriptUrl;
if (!isPublicUrl(url) || !url.startsWith("https://")) {
return apiError("Umami URL must be a public HTTPS endpoint", 422, {
errorCode: "url_not_public",
});
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
const start = performance.now();
try {
const res = await fetch(url, {
method: "GET",
signal: controller.signal,
cache: "no-store",
// `manual` neutralises a redirect-follow SSRF — even though the URL was
// checked with isPublicUrl(), a public host can 302 to a private IP.
redirect: "manual",
});
const latencyMs = Math.round(performance.now() - start);
if (res.status >= 300 && res.status < 400) {
annotate({ meta: { umami_test_status: res.status } });
return apiError("Umami URL redirects — check configuration", 502, {
errorCode: "redirected",
});
}
// Cap response read to 256 KB so a hostile public server can't stream
// gigabytes through us; combined with the 5 s abort, RAM use stays bounded.
let body = "";
if (res.body) {
const reader = res.body.getReader();
const decoder = new TextDecoder();
let total = 0;
try {
while (total < MAX_BODY_BYTES) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
body += decoder.decode(value, { stream: true });
}
body += decoder.decode();
} finally {
reader.releaseLock();
}
}
if (!res.ok) {
annotate({
meta: {
umami_test_status: res.status,
umami_test_latency_ms: latencyMs,
},
});
return apiError("Umami connection failed", 502, {
errorCode: "upstream_error",
});
}
// Look for tracker-shape signal rather than a literal "umami" substring —
// a 404 page that mentions "Powered by Umami" would otherwise slip through.
const hasMarker = /umami\.track|window\.umami|"umami"/i.test(body);
return apiSuccess({
ok: hasMarker,
statusCode: res.status,
latencyMs,
hasMarker,
});
} catch (e) {
const err = e as Error;
const isAbort = err.name === "AbortError";
const code = isAbort ? "timeout" : "connection_failed";
annotate({
meta: {
umami_test_code: code,
umami_test_error: redact(err.message).slice(0, 300),
},
});
return apiError(
isAbort ? "Umami request timed out" : "Umami connection failed",
502,
{ errorCode: code },
);
} finally {
clearTimeout(timer);
}
});
+1 -1
View File
@@ -15,7 +15,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
type RouteParams = { params: Promise<{ id: string }> };
+1 -1
View File
@@ -43,7 +43,7 @@ import {
} from "@/lib/validations/moodlog";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
const MAX_ENTRIES_PER_BATCH = 500;
const BATCH_RATE_LIMIT_MAX = 60;
+1 -1
View File
@@ -18,7 +18,7 @@ import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { invalidateUserMood } from "@/lib/cache/invalidate";
import { recomputeMoodBucketsForEntry } from "@/lib/mood/rollups";
import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -53,9 +53,9 @@ vi.mock("next/headers", () => ({
// Stub the warm-up helper so test runs don't fire the real
// `recomputeUserMoodRollups` aggregate. The warm-up is fire-and-
// forget on the route so its return value is irrelevant for parity.
vi.mock("@/lib/mood/rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/mood/rollups")>(
"@/lib/mood/rollups",
vi.mock("@/lib/rollups/mood-rollups", async () => {
const actual = await vi.importActual<typeof import("@/lib/rollups/mood-rollups")>(
"@/lib/rollups/mood-rollups",
);
return {
...actual,
+1 -1
View File
@@ -7,7 +7,7 @@ import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import {
ensureUserMoodRollupsFresh,
readMoodDayRollups,
} from "@/lib/mood/rollups";
} from "@/lib/rollups/mood-rollups";
export const dynamic = "force-dynamic";
@@ -0,0 +1,181 @@
/**
* GET /api/notifications/status route tests.
*
* Pins the SB-6 v1.4.40 contract — the response carries BOTH the
* existing `channels` array (channel reliability state for the Settings
* page) AND a new `events` map keyed by event-type whose values shape
* `{ lastDeliveredAt: ISO8601 | null }`. The iOS NotificationsScreen
* reads `events` to render "last delivered Xh ago" per category.
*
* Stability rules pinned here so a future refactor can't silently
* regress them:
* 1. 401 when unauthenticated (no DB lookup).
* 2. Every known EventType is present in the map, even for an
* empty-state user — value defaults to `{ lastDeliveredAt: null }`.
* 3. `MOOD_REMINDER.lastDeliveredAt` reads the latest
* `MoodReminderDispatch.dispatchedAt` and serialises it as ISO8601.
* 4. The existing `channels` array shape is untouched (backwards-
* compatible — the Settings card still works against this route).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
notificationChannel: {
findMany: vi.fn(),
},
moodReminderDispatch: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { EVENT_TYPES } from "@/lib/notifications/types";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([]);
vi.mocked(prisma.moodReminderDispatch.findFirst).mockResolvedValue(null);
});
describe("GET /api/notifications/status — auth", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(401);
// DB must not be touched before auth succeeds.
expect(prisma.notificationChannel.findMany).not.toHaveBeenCalled();
expect(prisma.moodReminderDispatch.findFirst).not.toHaveBeenCalled();
});
});
describe("GET /api/notifications/status — events map", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
it("empty-state user gets every known category with null", async () => {
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
channels: unknown[];
events: Record<string, { lastDeliveredAt: string | null }>;
};
};
expect(body.data.channels).toEqual([]);
// Every known event-type must be present so iOS doesn't have to
// special-case missing keys.
for (const ev of EVENT_TYPES) {
expect(body.data.events).toHaveProperty(ev);
expect(body.data.events[ev]).toEqual({ lastDeliveredAt: null });
}
});
it("populates MOOD_REMINDER.lastDeliveredAt from the latest dispatch row", async () => {
const dispatchedAt = new Date("2026-05-17T22:00:00.000Z");
vi.mocked(prisma.moodReminderDispatch.findFirst).mockResolvedValueOnce({
dispatchedAt,
} as never);
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
events: Record<string, { lastDeliveredAt: string | null }>;
};
};
expect(body.data.events.MOOD_REMINDER.lastDeliveredAt).toBe(
dispatchedAt.toISOString(),
);
// Other categories still null — no cross-contamination.
expect(body.data.events.MEDICATION_REMINDER.lastDeliveredAt).toBeNull();
expect(body.data.events.MEASUREMENT_ANOMALY.lastDeliveredAt).toBeNull();
// Scoped to the calling user.
expect(prisma.moodReminderDispatch.findFirst).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { dispatchedAt: "desc" },
select: { dispatchedAt: true },
});
});
});
describe("GET /api/notifications/status — channels shape (backwards compat)", () => {
beforeEach(() => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
it("keeps the existing channel-array shape so the Settings card keeps working", async () => {
// Fixture mirrors the Prisma `NotificationChannel` row shape the
// route reads. The Settings card destructures `data.channels`, so
// adding `events` must NOT shift the channels payload.
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([
{
id: "ch-1",
userId: "user-1",
type: "APNS",
enabled: true,
disabledReason: null,
consecutiveFailures: 0,
lastSuccessAt: new Date("2026-05-17T20:00:00.000Z"),
lastFailureAt: null,
lastFailureReason: null,
nextRetryAt: null,
},
] as never);
const res = await (GET as unknown as () => Promise<Response>)();
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
channels: Array<{
id: string;
type: string;
state: string;
lastSuccessAt: string | null;
}>;
events: Record<string, { lastDeliveredAt: string | null }>;
};
};
expect(body.data.channels).toHaveLength(1);
expect(body.data.channels[0]).toMatchObject({
id: "ch-1",
type: "APNS",
state: "active",
lastSuccessAt: "2026-05-17T20:00:00.000Z",
});
// `events` lives alongside `channels` so old consumers ignore the
// new key automatically.
expect(body.data.events).toBeDefined();
});
});
+60 -7
View File
@@ -4,8 +4,8 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError } from "@/lib/api-response";
import { CHANNEL_TYPE_LABELS } from "@/lib/notifications/types";
import type { ChannelType } from "@/lib/notifications/types";
import { CHANNEL_TYPE_LABELS, EVENT_TYPES } from "@/lib/notifications/types";
import type { ChannelType, EventType } from "@/lib/notifications/types";
import { reEnableChannel } from "@/lib/notifications/channel-state";
export const dynamic = "force-dynamic";
@@ -45,21 +45,74 @@ function deriveState(channel: {
return "active";
}
/**
* Per-event-type "last delivered" map. SB-6 v1.4.40 — drives the iOS
* NotificationsScreen "last-delivered X h ago" line so the user can tell
* at a glance whether the channel is actually firing for the category
* they care about.
*
* Source-of-truth strategy: aggregate from whichever per-event ledger
* exists today.
* - `MOOD_REMINDER` → `MoodReminderDispatch.dispatchedAt` (the
* idempotency ledger written by `mood-reminder.ts`).
* - everything else → `null` for now. As future categories grow their
* own ledger (e.g. a `MedicationReminderDispatch` table planned for a
* later phase), wire them in below — the iOS contract is stable
* around "null means no data yet", so adding sources is additive.
*
* Every known category is always present in the map (with `null` when
* no data exists) so the iOS layer can render an empty-state row
* without conditional plumbing.
*/
type EventStatus = { lastDeliveredAt: string | null };
async function loadEventStatuses(
userId: string,
): Promise<Record<EventType, EventStatus>> {
// Initialise the map with every known event-type → null so the
// response shape is stable regardless of which ledgers have rows.
const out = Object.fromEntries(
EVENT_TYPES.map((ev) => [ev, { lastDeliveredAt: null as string | null }]),
) as Record<EventType, EventStatus>;
// MOOD_REMINDER — read the latest row out of the idempotency ledger.
const lastMood = await prisma.moodReminderDispatch.findFirst({
where: { userId },
orderBy: { dispatchedAt: "desc" },
select: { dispatchedAt: true },
});
if (lastMood) {
out.MOOD_REMINDER = {
lastDeliveredAt: lastMood.dispatchedAt.toISOString(),
};
}
return out;
}
/**
* GET /api/notifications/status
*
* Returns the per-channel reliability state — what the Settings →
* Notifications page paints into "Active / Auto-disabled / Sending paused"
* badges + last-send / last-failure timestamps.
*
* SB-6 v1.4.40 — additionally returns a per-event-type `events` map so
* the iOS NotificationsScreen can render "last delivered X h ago" per
* category. `events[category].lastDeliveredAt` is null when the user
* has never received that event-type (or no ledger source exists yet).
*/
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "notifications.status.get" } });
const channels = await prisma.notificationChannel.findMany({
where: { userId: user.id },
orderBy: { type: "asc" },
});
const [channels, events] = await Promise.all([
prisma.notificationChannel.findMany({
where: { userId: user.id },
orderBy: { type: "asc" },
}),
loadEventStatuses(user.id),
]);
const data: ChannelStatus[] = channels.map((ch) => ({
id: ch.id,
@@ -75,7 +128,7 @@ export const GET = apiHandler(async () => {
nextRetryAt: ch.nextRetryAt?.toISOString() ?? null,
}));
return apiSuccess({ channels: data });
return apiSuccess({ channels: data, events });
});
const reEnableSchema = z.object({
+1 -1
View File
@@ -13,7 +13,7 @@ import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { locales, type Locale } from "@/lib/i18n/config";
import { recomputeMedicationComplianceForEvent } from "@/lib/medications/compliance-rollups";
import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups";
interface TelegramUpdate {
update_id: number;
+4 -3
View File
@@ -5,6 +5,7 @@ import Link from "next/link";
import { Loader2, Pill } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -76,7 +77,7 @@ export default function InsightsMedikamentePage() {
const { t, locale } = useTranslations();
const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
queryKey: ["insights", "comprehensive"],
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
@@ -87,7 +88,7 @@ export default function InsightsMedikamentePage() {
});
const { data: status, isLoading: isStatusLoading } = useQuery({
queryKey: ["insights", "medication-compliance-status", locale],
queryKey: queryKeys.insightsMedicationComplianceStatus(locale),
queryFn: async () => {
const res = await fetch(
`/api/insights/medication-compliance-status?locale=${locale}`,
@@ -261,7 +262,7 @@ function MedicationComplianceCalendar({
}) {
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: ["compliance-chart-inline", medicationId],
queryKey: queryKeys.medicationComplianceChart(medicationId),
queryFn: async () => {
const res = await fetch(`/api/medications/${medicationId}/compliance`);
if (!res.ok) return null;
+2 -1
View File
@@ -6,6 +6,7 @@ import Link from "next/link";
import { TrendingUp } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useScrollResetOnRoute } from "@/hooks/use-scroll-reset-on-route";
import { useTranslations } from "@/lib/i18n/context";
@@ -168,7 +169,7 @@ export default function InsightsPage() {
// reported zero measurements; while it's in-flight the page paints
// the regular shell and the tiles fill in as their data lands.
const { data, isLoading, isFetched } = useQuery({
queryKey: ["insights", "comprehensive"],
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error(t("insights.loadError"));
+2 -1
View File
@@ -7,6 +7,7 @@ import { Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { queryKeys } from "@/lib/query-keys";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
@@ -48,7 +49,7 @@ export default function InsightsStimmungPage() {
// Reuse the mother-page comprehensive query — TanStack Query
// dedups so this is a free cache read for the common case.
const { data: comprehensive } = useQuery({
queryKey: ["insights", "comprehensive"],
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
+63 -8
View File
@@ -1,6 +1,6 @@
"use client";
import React, { useMemo, useState } from "react";
import React, { Suspense, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import {
@@ -48,6 +48,16 @@ import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
import { RecentWorkoutsTile } from "@/components/dashboard/recent-workouts-tile";
// v1.4.40 W-RSC — module-scope so the option object is stable across
// renders (audit-M2). Pre-fix the same literal was declared inside the
// component body, so every render created a fresh `{}` reference;
// TanStack does a shallow compare and shrugs at the equivalent values,
// but the slot is one future-callback-field away from cache poisoning.
const DASHBOARD_QUERY_OPTS = {
staleTime: 60_000,
refetchOnWindowFocus: false,
} as const;
const MoodChart = dynamic(
() =>
import("@/components/charts/mood-chart").then((mod) => ({
@@ -218,10 +228,9 @@ export default function DashboardPage() {
// storm. None of these are real-time data; minute-scale staleness
// is fine and the dashboard's chart queries already match this
// cadence.
const DASHBOARD_QUERY_OPTS = {
staleTime: 60_000,
refetchOnWindowFocus: false,
} as const;
//
// v1.4.40 W-RSC — `DASHBOARD_QUERY_OPTS` hoisted to module scope
// (audit-M2 — stable reference across renders).
// v1.4.39.2 — split the dashboard's analytics consumption so the
// tile-strip paints from the slim slice and the BD-Zielbereich +
@@ -559,7 +568,16 @@ export default function DashboardPage() {
upperBound: 55,
},
);
const hour = user?.timezone ? getHourForTimeZone(user.timezone) : null;
// v1.4.40 W-RSC — memoise the per-render `Intl.DateTimeFormat`
// instantiation (audit-H4). The greeting derived from the hour only
// changes when the user's timezone changes; recomputing it every
// render churned the parts-array and walked the format ICU twice per
// render across a 1 400-line component body.
const userTimezone = user?.timezone;
const hour = useMemo(
() => (userTimezone ? getHourForTimeZone(userTimezone) : null),
[userTimezone],
);
const timeGreeting =
hour == null
? t("dashboard.greeting.day")
@@ -1421,14 +1439,51 @@ export default function DashboardPage() {
>
{trendCards.map((entry) => (
<div key={entry.id} className="flex min-w-0">
{entry.node}
{/*
* v1.4.40 W-RSC — per-tile `<Suspense>` boundary.
* The tile body is synchronous today (every tile
* reads from the merged `data` memo which already
* gates on the slim/thick analytics queries on the
* parent), so this fallback never paints in the
* current composition. The structural boundary
* matches the chart-row layer below so the entire
* dashboard composition reads as a streaming-ready
* grid of independently suspending cells — and
* future RSC hoists of any tile slot won't need a
* second pass to add the fallback infrastructure.
*/}
<Suspense fallback={null}>{entry.node}</Suspense>
</div>
))}
</div>
)}
{charts.map((entry) => (
<div key={entry.id} className="space-y-2">
{entry.node}
{/*
* v1.4.40 W-RSC — per-tile `<Suspense>` boundary so each
* chart paints independently rather than the row blocking
* on the slowest fetch (audit-H2 + brief C1). The
* `next/dynamic({ ssr: false })` lazy-load contract on
* `HealthChartDynamic` / `MoodChart` /
* `MedicationComplianceChart` already paints a
* `<ChartSkeleton>` while the JS chunk is in flight; this
* Suspense layer lifts the same skeleton to a streaming-
* compatible boundary so a future migration to
* `useSuspenseQuery` (when we replace the per-chart
* `["chart-data", …]` fetches with the slim-analytics-
* derived store) automatically buckets each chart's
* loading state to its own cell, with no further
* call-site changes.
*
* Today, the boundary is a structural no-op for the
* dynamic-loaded charts because their loading skeleton
* lives inside the dynamic factory. The benefit is
* future-proofing the composition: any descendant that
* later suspends (e.g. an RSC migration of a static
* legend, or a server-streamed sparkline) gets its own
* fallback without re-architecting the row.
*/}
<Suspense fallback={<ChartSkeleton />}>{entry.node}</Suspense>
{entry.count != null ? <TrendHint count={entry.count} /> : null}
</div>
))}
+123 -26
View File
@@ -4,13 +4,15 @@ import { renderToStaticMarkup } from "react-dom/server";
import PrivacyPage from "../page";
/**
* v1.4.26 — Public privacy-policy page contract.
* v1.4.40 W-PRIVACY (SB-3) — Bilingual public privacy-policy contract.
*
* The page is the App-Store-submission blocker (Apple BLOCKER 2 of 5).
* These tests pin the structural commitments that any future redesign
* must preserve:
*
* 1. The H1 says "Privacy Policy".
* 1. The H1 carries both the German and English title so an Apple US
* reviewer AND a German DPA reviewer recognise the document
* without locale switching.
* 2. The page advertises a "Last updated" date alongside a policy
* version stamp — App-Store reviewers and German DPA inspectors
* use both to gauge currency.
@@ -18,31 +20,42 @@ import PrivacyPage from "../page";
* enumerated verbatim by identifier. Drift from the iOS source
* (`HealthKitWireConverter.swift` + `HealthKitService.swift`)
* would be a compliance hole.
* 4. The numbered section headings are all present, so the policy
* remains scannable without re-rendering with a specific locale.
* 4. The numbered section headings are all present (English form, so
* the scannable spine matches the in-app `Settings → Privacy`
* link target and the table of contents above each section).
* 5. v1.4.40 SB-3 additions — consent receipt endpoint, TLS 1.3,
* operator email + deletion-route disclosure, retention windows.
*/
function render() {
return renderToStaticMarkup(<PrivacyPage />);
}
const POLICY_VERSION = "1.4.40";
const LAST_UPDATED = "2026-05-18";
describe("<PrivacyPage>", () => {
it("renders without crashing and includes the H1", () => {
it("renders without crashing and includes the bilingual H1", () => {
const html = render();
// Single bilingual title — DE + EN side by side so a German DPA
// and an Apple US reviewer both see the document is for them.
expect(html).toContain("Datenschutzerklärung");
expect(html).toContain("Privacy Policy");
});
it("advertises the policy version + last-updated date", () => {
const html = render();
expect(html).toContain("Policy version 1.4.26");
expect(html).toContain("Last updated: 2026-05-15");
expect(html).toContain(`Policy version ${POLICY_VERSION}`);
expect(html).toContain(`Last updated: ${LAST_UPDATED}`);
expect(html).toContain(`Stand: ${LAST_UPDATED}`);
expect(html).toContain('data-slot="privacy-last-updated"');
});
it("renders the numbered section headings", () => {
const html = render();
// 11 numbered sections — H2 headings render the leading numeral so
// the reader can navigate without an explicit table of contents.
// 11 numbered sections — H2 headings carry the English form as the
// canonical scan spine, with the German title appearing inline in
// each section body. The TOC at the top mirrors both.
const expectedHeadings = [
"1. Overview",
"2. Data we collect",
@@ -61,17 +74,36 @@ describe("<PrivacyPage>", () => {
}
});
it("renders the bilingual section structure with DE + EN bodies", () => {
const html = render();
// Each section ships a `data-slot="privacy-section-de"` body and a
// collapsible `data-slot="privacy-section-en"` translation. 11
// sections total.
const deSlotCount = (
html.match(/data-slot="privacy-section-de"/g) ?? []
).length;
const enSlotCount = (
html.match(/data-slot="privacy-section-en"/g) ?? []
).length;
expect(deSlotCount).toBe(11);
expect(enSlotCount).toBe(11);
// The English translation `<details>` carries the "English
// translation" summary label so a reviewer can find it without
// guessing.
expect(html).toContain("English translation");
// The German bodies are `lang="de"` so screen readers switch
// pronunciation correctly.
expect(html).toContain('lang="de"');
expect(html).toContain('lang="en"');
});
it("enumerates every HealthKit quantity type read by the iOS app", () => {
const html = render();
// Source of truth: HealthKitService.defaultReadTypes +
// HealthKitWireConverter.preferredUnit in the iOS repo. Every
// identifier the iOS app reads must appear verbatim so an App
// Store reviewer can confirm coverage. The list must stay sorted
// by category in the page itself (vitals, cardio, activity,
// audio, daylight) — that's a UX commitment, not a test
// commitment; we just check membership here.
// Store reviewer can confirm coverage.
const requiredIdentifiers = [
// Vitals (point metrics)
"bodyMass",
"bodyFatPercentage",
"bodyTemperature",
@@ -79,20 +111,16 @@ describe("<PrivacyPage>", () => {
"bloodPressureDiastolic",
"bloodGlucose",
"oxygenSaturation",
// Cardio
"heartRate",
"restingHeartRate",
"heartRateVariabilitySDNN",
"vo2Max",
// Activity
"stepCount",
"activeEnergyBurned",
"flightsClimbed",
"distanceWalkingRunning",
// Audio exposure
"environmentalAudioExposure",
"headphoneAudioExposure",
// Time in Daylight (iOS 17+)
"timeInDaylight",
];
expect(
@@ -110,9 +138,6 @@ describe("<PrivacyPage>", () => {
it("lists sleepAnalysis as the sole HKCategoryTypeIdentifier read", () => {
const html = render();
expect(html).toContain("sleepAnalysis");
// Per-stage breakdown is the differentiator vs. a single sleep
// duration — name the four stage labels so the disclosure stays
// explicit.
expect(html).toContain("Awake");
expect(html).toContain("REM");
expect(html).toContain("Core");
@@ -142,24 +167,96 @@ describe("<PrivacyPage>", () => {
it("states the EU MDR medical-device boundary verbatim", () => {
const html = render();
expect(html).toContain("not a medical device");
expect(html).toContain("kein Medizinprodukt");
expect(html).toContain("2017/745");
expect(html).toContain("MDCG 2021-24");
});
it("links to the GDPR-request contact channel without exposing PII", () => {
it("discloses the HealthKit data-flow path (HK sample → HTTPS → server)", () => {
const html = render();
// SB-3 requirement 2: how HealthKit samples reach the server.
expect(html).toContain("POST /api/measurements");
expect(html).toContain("TLS 1.3");
});
it("discloses AI off-device transit with the named providers and consent gate", () => {
const html = render();
// SB-3 requirement 3: which AI providers receive prompts and the
// explicit guarantees (no raw HK identifiers, off by default,
// encrypted in transit).
expect(html).toContain("off by default");
expect(html).toContain("never includes raw HealthKit identifiers");
expect(html).toContain("Anthropic");
expect(html).toContain("OpenAI");
});
it("links the consent receipt endpoint with the retention window", () => {
const html = render();
// SB-3 requirement 4: consent receipt persistence — endpoint +
// retention period.
expect(html).toContain("GET /api/account/consents");
expect(html).toContain("consent receipt");
expect(html).toContain("five years");
});
it("declares encryption in transit (TLS 1.3 + HSTS + cert pinning)", () => {
const html = render();
// SB-3 requirement 5.
expect(html).toContain("TLS 1.3");
expect(html).toContain("HSTS");
// Cert pinning is described in two languages; both bodies mention
// the practice but with different surrounding whitespace, so check
// a tolerant regex.
expect(html).toMatch(/certificate\s+pinning/i);
expect(html).toMatch(/Zertifikat-Pinning/);
});
it("states the server location and operator framing", () => {
const html = render();
// SB-3 requirement 6: operated by an individual in Germany on
// Hetzner; no third-party hosting except AI providers.
expect(html).toContain("Germany");
expect(html).toContain("Hetzner");
expect(html).toContain("operator");
});
it("publishes the retention defaults (5y / 90d / 30d APNs)", () => {
const html = render();
// SB-3 requirement 7.
expect(html).toContain("5-year window");
expect(html).toContain("Audit logs: 90 days");
expect(html).toContain("Failed APNs deliveries: 30 days");
});
it("discloses the GDPR Art. 17 deletion route + cascade behaviour", () => {
const html = render();
// SB-3 requirement 8: Settings → Daten → Konto löschen cascades
// through User.delete + onDelete: Cascade.
expect(html).toContain("Konto löschen");
expect(html).toContain("Delete account");
expect(html).toContain("User.delete");
expect(html).toContain("onDelete: Cascade");
});
it("publishes the operator email + GitHub fallback channel", () => {
const html = render();
// SB-3 requirement 9: operator contact. The body uses the
// "operator" framing per the v1.4.20 PII guidance — only the
// email address attaches the actual contact.
expect(html).toContain("mailto:mbombeck@gmail.com");
expect(html).toContain("github.com/MBombeck/HealthLog/issues");
// Marc-Voice rule: no personal name in user-facing artefacts. The
// policy must speak in terms of "the operator" / "the maintainer"
// not Marc's full name.
// policy speaks in terms of "the operator" / "the controller"
// not Marc's full name. Only the bare email address attaches the
// contact route.
expect(html).not.toMatch(/Marc[- ]?Andr/i);
expect(html).not.toContain("Bombeck ");
expect(html).not.toMatch(/\bBombeck\s+[A-Z]/);
});
it("renders a discoverable footer with the version and license", () => {
const html = render();
expect(html).toContain('data-slot="privacy-footer"');
expect(html).toContain("AGPL-3.0");
expect(html).toContain("1.4.26");
expect(html).toContain(POLICY_VERSION);
});
});
+1646 -710
View File
File diff suppressed because it is too large. Load diff
+8 -3
View File
@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import {
ResponsiveContainer,
ComposedChart,
@@ -541,8 +542,12 @@ export function HealthChart({
}, [rangePoints, effectiveCompareBaseline]);
const { data, isLoading } = useQuery({
queryKey: [
"chart-data",
// v1.4.40 W-RSC — route the chart-data key through `queryKeys.chartData`
// so the `["chart-data"]` prefix lands in `measurementDependentKeys`
// and a fresh measurement evicts every per-chart daily cache in one
// pass (audit C2). The factory-packed tuple is byte-identical with
// the pre-v1.4.40 inline literal so the cache layout stays stable.
queryKey: queryKeys.chartData(
types.join(","),
valueMode,
bmiDivisor ?? "no-bmi",
@@ -555,7 +560,7 @@ export function HealthChart({
// re-running the unbounded walk.
fetchWindow.from,
fetchWindow.to,
],
),
// v1.4.28 FB-D2 — cache the bounded window for a minute so tab
// navigation between insights sub-pages does not re-fire every
// chart's fetch. `gcTime` keeps the inactive cache for five
@@ -34,6 +34,7 @@
*/
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react";
import { queryKeys } from "@/lib/query-keys";
import {
ResponsiveContainer,
ComposedChart,
@@ -226,8 +227,12 @@ export function MedicationComplianceChart({
// room of the weight / BMI charts.
const viewportWidth = useViewportWidth();
// v1.4.40 W-RSC — factory-routed key so the prefix
// `["dashboard-medication-compliance"]` lands in
// `medicationDependentKeys` and an intake POST refreshes the chart
// immediately rather than waiting for `staleTime` (audit L4).
const { data, isLoading } = useQuery({
queryKey: ["medication-compliance-chart", days],
queryKey: queryKeys.dashboardMedicationCompliance(days),
queryFn: async (): Promise<DailyCompliancePoint[]> => {
const res = await fetch(
`/api/medications/intake?scope=compliance&days=${days}`,
+18 -1
View File
@@ -2,6 +2,7 @@
import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { queryKeys } from "@/lib/query-keys";
import {
ResponsiveContainer,
ComposedChart,
@@ -312,8 +313,22 @@ export function MoodChart({
const displayTitle = title ?? t("charts.mood");
// v1.4.40 W-RSC — align the chart's queryKey with the dashboard's
// `useQuery({ queryKey: queryKeys.moodAnalytics() })` call against the
// same `/api/mood/analytics` endpoint. Pre-fix the chart fetched its
// own copy under `["mood-chart-data"]` (audit C1) — TanStack treated
// the two keys as separate cache slots and the cold-mount dashboard
// fired the request twice (once at `app/page.tsx:291`, once here on
// the chart's first render). The dedup eliminates a ~22 KB round-trip
// and frees one pg pool slot per cold mount.
//
// `staleTime: 60_000` matches the dashboard's `DASHBOARD_QUERY_OPTS`
// so a route swing back to the dashboard within a minute keeps the
// cache fresh; the chart's own subscription becomes the cache writer
// for any insights / mood sub-page that mounts the chart without the
// dashboard parent having fetched first.
const { data, isLoading } = useQuery({
queryKey: ["mood-chart-data"],
queryKey: queryKeys.moodAnalytics(),
queryFn: async (): Promise<MoodAnalyticsData> => {
const res = await fetch("/api/mood/analytics");
if (!res.ok) throw new Error("Failed to fetch mood analytics");
@@ -321,6 +336,8 @@ export function MoodChart({
return json.data as MoodAnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60_000,
refetchOnWindowFocus: false,
});
const chartData = useMemo((): ChartDataPoint[] | undefined => {
+11 -9
View File
@@ -3,6 +3,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import { queryKeys } from "@/lib/query-keys";
import {
COMPARISON_BASELINES,
resolveDashboardLayout,
@@ -26,7 +27,7 @@ export function CompareToggle({ className }: { className?: string }) {
const queryClient = useQueryClient();
const { data: layoutData } = useQuery({
queryKey: ["user", "dashboardWidgets"],
queryKey: queryKeys.dashboardWidgets(),
queryFn: async () => {
const res = await fetch("/api/dashboard/widgets");
if (!res.ok) throw new Error("failed");
@@ -52,15 +53,14 @@ export function CompareToggle({ className }: { className?: string }) {
},
onMutate: async (next) => {
await queryClient.cancelQueries({
queryKey: ["user", "dashboardWidgets"],
queryKey: queryKeys.dashboardWidgets(),
});
const prev = queryClient.getQueryData<DashboardLayout>([
"user",
"dashboardWidgets",
]);
const prev = queryClient.getQueryData<DashboardLayout>(
queryKeys.dashboardWidgets(),
);
if (prev) {
queryClient.setQueryData<DashboardLayout>(
["user", "dashboardWidgets"],
queryKeys.dashboardWidgets(),
{ ...prev, comparisonBaseline: next },
);
}
@@ -68,11 +68,13 @@ export function CompareToggle({ className }: { className?: string }) {
},
onError: (_err, _next, ctx) => {
if (ctx?.prev) {
queryClient.setQueryData(["user", "dashboardWidgets"], ctx.prev);
queryClient.setQueryData(queryKeys.dashboardWidgets(), ctx.prev);
}
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ["user", "dashboardWidgets"] });
queryClient.invalidateQueries({
queryKey: queryKeys.dashboardWidgets(),
});
},
});
@@ -58,7 +58,7 @@ import {
* shaped the same as `MoodForm`).
*
* On success: invalidate `medicationDependentKeys` (medications +
* analytics + insights + intake-summary + achievements) PLUS the
* analytics + insights + achievements) PLUS the
* inline per-medication compliance chart key so the detail page tile
* refreshes if it happens to be mounted elsewhere in the app.
*
@@ -9,6 +9,7 @@ import { useWorkouts } from "@/hooks/use-workouts";
import { InsightsTabStrip } from "@/components/insights/insights-tab-strip";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { useAnalyticsQuery } from "@/lib/queries/use-analytics-query";
import { queryKeys } from "@/lib/query-keys";
import type { InsightInputs } from "@/lib/insights/metric-availability";
/**
@@ -66,7 +67,7 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) {
// event-driven gating branches. Sub-pages read the same key so the
// payload lands once per route.
const comprehensiveQuery = useQuery({
queryKey: ["insights", "comprehensive"],
queryKey: queryKeys.insightsComprehensive(),
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
+8 -3
View File
@@ -2,6 +2,7 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { queryKeys } from "@/lib/query-keys";
export interface AuthUser {
id: string;
@@ -33,7 +34,11 @@ async function fetchMe(): Promise<AuthUser> {
export function useAuth() {
const query = useQuery({
queryKey: ["auth", "me"],
// v1.4.40 W-RSC — factory-routed to `queryKeys.authMe()`. Pre-fix
// the literal `["auth", "me"]` was the canonical example in
// audit-H1 of factory drift. The prefix `["auth"]` still matches
// `queryKeys.auth()` invalidations.
queryKey: queryKeys.authMe(),
queryFn: fetchMe,
retry: false,
staleTime: 5 * 60 * 1000,
@@ -57,8 +62,8 @@ export function useLogout() {
await fetch("/api/auth/logout", { method: "POST" });
},
onSuccess: () => {
queryClient.setQueryData(["auth", "me"], null);
queryClient.invalidateQueries({ queryKey: ["auth"] });
queryClient.setQueryData(queryKeys.authMe(), null);
queryClient.invalidateQueries({ queryKey: queryKeys.auth() });
router.push("/auth/login");
},
});
+64
View File
@@ -0,0 +1,64 @@
/**
* v1.4.40 W-POOL — pool-config contract for `src/lib/db.ts`.
*
* The v1.4.39 empirical cold-mount trace
* (`.planning/round-v1439-empirical-trace.md` § B2) traced the
* Wave-B/C dashboard-query stall to the Prisma `pg.Pool` falling
* back to the library default ceiling of 10 connections. W-POOL
* pinned the ceiling to 20 via `getPoolMax()`; this test prevents a
* future refactor from silently dropping the override and re-introducing
* the saturation regression on a power-user cold mount.
*
* The test only exercises the `getPoolMax()` env-resolver. Constructing
* a real `PrismaClient` requires a live `DATABASE_URL`, which would
* make this an integration test instead of a unit test — the helper
* isolation is intentional.
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { getPoolMax } from "../db";
describe("getPoolMax", () => {
const originalEnv = process.env.DATABASE_POOL_MAX;
beforeEach(() => {
delete process.env.DATABASE_POOL_MAX;
});
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.DATABASE_POOL_MAX;
} else {
process.env.DATABASE_POOL_MAX = originalEnv;
}
});
it("defaults to 20 when DATABASE_POOL_MAX is unset", () => {
expect(getPoolMax()).toBe(20);
});
it("honours DATABASE_POOL_MAX when it parses to a positive int", () => {
process.env.DATABASE_POOL_MAX = "30";
expect(getPoolMax()).toBe(30);
});
it("falls back to 20 on a malformed DATABASE_POOL_MAX", () => {
process.env.DATABASE_POOL_MAX = "not-a-number";
expect(getPoolMax()).toBe(20);
});
it("falls back to 20 on a non-positive DATABASE_POOL_MAX", () => {
process.env.DATABASE_POOL_MAX = "0";
expect(getPoolMax()).toBe(20);
process.env.DATABASE_POOL_MAX = "-5";
expect(getPoolMax()).toBe(20);
});
it("never returns the library default of 10 on the happy path", () => {
// The whole point of the W-POOL change. If anybody removes the
// `max:` config from `createPrismaClient` and reverts to the
// library default, this assertion stops the regression before the
// next saturated cold mount.
expect(getPoolMax()).toBeGreaterThanOrEqual(20);
});
});
-13
View File
@@ -1,7 +1,6 @@
import { describe, it, expect } from "vitest";
import {
mgdlToMmol,
mmolToMgdl,
convertGlucose,
resolveGlucoseUnit,
thresholdMetricForContext,
@@ -14,18 +13,6 @@ describe("glucose conversion", () => {
expect(mgdlToMmol(70)).toBeCloseTo(3.9, 1); // hypoglycemia threshold
});
it("converts mmol/L to mg/dL rounded to integer", () => {
expect(mmolToMgdl(5.5)).toBe(99);
expect(mmolToMgdl(7.0)).toBe(126);
});
it("roundtrips within 1 mg/dL", () => {
for (const mgdl of [70, 99, 100, 126, 140, 200, 300]) {
const back = mmolToMgdl(mgdlToMmol(mgdl));
expect(Math.abs(back - mgdl)).toBeLessThanOrEqual(1);
}
});
it("convertGlucose dispatches to the right unit", () => {
expect(convertGlucose(100, "mg/dL")).toBe(100);
expect(convertGlucose(100, "mmol/L")).toBe(5.5);
+161
View File
@@ -1,3 +1,5 @@
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, it, expect, vi } from "vitest";
import {
queryKeys,
@@ -34,6 +36,51 @@ describe("queryKeys factory", () => {
expect(queryKeys.analytics()).toEqual(["analytics"]);
expect(queryKeys.analytics(undefined)).toEqual(["analytics"]);
});
// v1.4.40 W-RSC — the four new factory entries from this wave each
// need a byte-stable contract so the audit-cited drift cases stay
// pinned. A future rename of any of these literals would land a
// matching test failure rather than silently breaking cache layout.
it("exposes the authMe shape so useAuth doesn't drift back to a bare literal", () => {
expect(queryKeys.authMe()).toEqual(["auth", "me"]);
});
it("exposes the userThresholds shape for Settings + targets", () => {
expect(queryKeys.userThresholds()).toEqual(["user", "thresholds"]);
});
it("packs chartData params into a stable tuple prefixed by chart-data", () => {
const key = queryKeys.chartData(
"WEIGHT",
"raw",
"no-bmi",
"Europe/Berlin",
"2026-04-01T00:00:00.000Z",
"2026-05-01T00:00:00.000Z",
);
expect(key[0]).toBe("chart-data");
expect(key.length).toBe(7);
// Numeric bmiDivisor is allowed (BMI charts pass `heightCm * heightCm`).
const bmiKey = queryKeys.chartData(
"WEIGHT",
"bmi",
30625,
"Europe/Berlin",
"2026-04-01T00:00:00.000Z",
"2026-05-01T00:00:00.000Z",
);
expect(bmiKey[3]).toBe(30625);
});
it("prefixes the dashboard medication-compliance key under one root", () => {
expect(queryKeys.dashboardMedicationCompliance(7)).toEqual([
"dashboard-medication-compliance",
7,
]);
expect(queryKeys.dashboardMedicationCompliance(30)[0]).toBe(
"dashboard-medication-compliance",
);
});
});
describe("dependent-key bundles", () => {
@@ -48,6 +95,16 @@ describe("dependent-key bundles", () => {
);
});
// v1.4.40 W-RSC — chart-data prefix now lives in the bundle so a
// fresh measurement evicts every per-chart daily-aggregate cache.
// Pre-fix the chart row stayed 60 s stale after a measurement save
// (audit-C2). Pin the bundle membership so a refactor of the chart
// queryKey factory can't silently drop the dependency.
it("measurementDependentKeys bundles the chart-data prefix (v1.4.40)", () => {
const keyStrings = measurementDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["chart-data"]));
});
it("moodDependentKeys bundle covers mood + analytics + targets", () => {
const keyStrings = moodDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["mood-entries"]));
@@ -63,6 +120,110 @@ describe("dependent-key bundles", () => {
JSON.stringify(["gamification", "achievements"]),
);
});
// v1.4.40 W-RSC — dashboard-medication-compliance prefix now lives in
// the bundle so an intake POST refreshes the dashboard chart in
// lockstep with the per-medication compliance-chart-inline tile
// (audit-L4).
it("medicationDependentKeys bundles the dashboard-medication-compliance prefix (v1.4.40)", () => {
const keyStrings = medicationDependentKeys.map((k) => JSON.stringify(k));
expect(keyStrings).toContain(JSON.stringify(["dashboard-medication-compliance"]));
});
});
/**
* v1.4.40 W-RSC — factory-bypass guard.
*
* Walks every `.ts` / `.tsx` file in `src/components/charts` and
* `src/app/page.tsx` (the dashboard + chart files we own in this wave)
* and fails if a literal `queryKey: [...]` slipped past the factory.
* Acts as a lint-style enforcement check in the absence of a custom
* ESLint rule — keeps the CI gate cheap and the failure message
* pointed at the exact file the contributor needs to fix.
*
* The audit (`.planning/round-v1439-arch-qa-frontend.md` §H1) found
* 154 bare queryKey sites at v1.4.39.3. We intentionally scope this
* guard tight to the dashboard + chart files this wave touched so a
* future wave can extend the directory list as it migrates the
* remaining sites — opt-in expansion beats a giant red CI on
* landing.
*/
describe("queryKey factory enforcement", () => {
function listFiles(dir: string): string[] {
const entries = readdirSync(dir);
const out: string[] = [];
for (const name of entries) {
const full = join(dir, name);
const st = statSync(full);
if (st.isDirectory()) {
if (name === "__tests__" || name === "node_modules") continue;
out.push(...listFiles(full));
} else if (name.endsWith(".ts") || name.endsWith(".tsx")) {
out.push(full);
}
}
return out;
}
const repoRoot = join(__dirname, "..", "..", "..");
// Files this wave migrated to the factory. Extend this list as
// future waves migrate their own surface; the audit-H1 long tail
// (admin/, settings/, medications/, integrations) stays out of the
// guard's scope until a follow-up wave routes those keys.
const guardedRoots = [
join(repoRoot, "src", "components", "charts"),
join(repoRoot, "src", "components", "comparison"),
join(repoRoot, "src", "app", "page.tsx"),
join(repoRoot, "src", "hooks", "use-auth.ts"),
];
function collect(): string[] {
const all: string[] = [];
for (const root of guardedRoots) {
const st = statSync(root);
if (st.isDirectory()) {
all.push(...listFiles(root));
} else {
all.push(root);
}
}
return all;
}
it("no guarded file declares a bare-literal `queryKey:` (factory enforcement)", () => {
const offenders: Array<{ file: string; line: number; snippet: string }> = [];
const files = collect();
for (const file of files) {
const text = readFileSync(file, "utf8");
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Match `queryKey:` followed by a `[` on the same line. We
// explicitly allow `queryKey: queryKeys.<entry>(…)` patterns
// and any indirect identifier the call site passes through
// (a `key` local, a `listKey` constant, etc.) so the guard
// only flags the literal-array shape that drove audit-H1.
const match = line.match(/queryKey\s*:\s*\[/);
if (!match) continue;
// Strip trailing comments + JSX comments before deciding.
const trimmed = line.replace(/\/\/.*$/, "").trim();
if (!/queryKey\s*:\s*\[/.test(trimmed)) continue;
offenders.push({
file: file.slice(repoRoot.length + 1),
line: i + 1,
snippet: trimmed,
});
}
}
expect(
offenders,
`Factory bypass — every \`queryKey:\` in guarded files must go through \`queryKeys.<entry>()\`. ` +
`Offenders:\n${offenders
.map((o) => ` ${o.file}:${o.line} ${o.snippet}`)
.join("\n")}`,
).toEqual([]);
});
});
describe("invalidateKeys", () => {
+1
View File
@@ -497,6 +497,7 @@ async function buildCoachSnapshotImpl(
userId,
type: { in: wantedTypes as never[] },
measuredAt: { gte: cutoff },
deletedAt: null,
},
orderBy: { measuredAt: "asc" },
select: { type: true, value: true, measuredAt: true },
@@ -1,31 +0,0 @@
import { describe, it, expect } from "vitest";
import { berlinDayKey } from "../berlin-day";
/**
* v1.4.22 W5 reconcile (Code-MED-3) — pin that the helper buckets a
* 23:30-Berlin reading on Tuesday into the Tuesday key, regardless of
* UTC offset (CET / CEST, DST).
*/
describe("berlinDayKey", () => {
it("buckets a 23:30-Berlin reading on Tuesday into Tuesday's key (CEST)", () => {
// 2025-10-25T21:30:00Z = 2025-10-25T23:30 CEST (Saturday before DST end)
expect(berlinDayKey(new Date("2025-10-25T21:30:00Z"))).toBe("2025-10-25");
});
it("buckets a 23:30-Berlin reading after DST end into the right Berlin day", () => {
// 2025-10-26 is the DST end day in Europe/Berlin (CET takes over).
// 2025-10-26T22:30:00Z = 2025-10-26T23:30 CET — still Sunday in Berlin.
expect(berlinDayKey(new Date("2025-10-26T22:30:00Z"))).toBe("2025-10-26");
});
it("crosses the UTC-midnight boundary in Berlin's favour", () => {
// 2026-05-09T22:30:00Z falls on 2026-05-10T00:30 CEST — Berlin says
// Sunday, UTC says Saturday. The helper must follow Berlin.
expect(berlinDayKey(new Date("2026-05-09T22:30:00Z"))).toBe("2026-05-10");
});
it("returns YYYY-MM-DD format (sortable, padded)", () => {
expect(berlinDayKey(new Date("2026-01-09T12:00:00Z"))).toBe("2026-01-09");
expect(berlinDayKey(new Date("2026-12-09T12:00:00Z"))).toBe("2026-12-09");
});
});
@@ -19,7 +19,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -29,7 +29,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { computeBpInTargetFastPath } from "../bp-in-target-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
@@ -22,7 +22,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import {
CORRELATION_WINDOW_DAYS,
computeCorrelationHypothesesFastPath,
@@ -1,10 +1,6 @@
import { describe, it, expect } from "vitest";
import type { DataPoint } from "../trends";
import {
pairByTimestamp,
pearsonCorrelation,
weeklyAverages,
} from "../correlations";
import { pairByTimestamp, pearsonCorrelation } from "../correlations";
function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
const now = Date.now();
@@ -107,57 +103,3 @@ describe("pearsonCorrelation", () => {
});
});
describe("weeklyAverages", () => {
it("returns empty for empty input", () => {
expect(weeklyAverages([])).toEqual([]);
});
it("groups data points by ISO week", () => {
const data = makePoints(
[70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83],
14,
);
const result = weeklyAverages(data);
// 14 days = 2-3 weeks
expect(result.length).toBeGreaterThanOrEqual(2);
expect(result.length).toBeLessThanOrEqual(3);
});
it("averages values within same week", () => {
const now = new Date();
// Find the Monday of current week
const day = now.getDay();
const monday = new Date(now);
monday.setDate(now.getDate() - day + (day === 0 ? -6 : 1));
monday.setHours(12, 0, 0, 0);
const tuesday = new Date(monday);
tuesday.setDate(monday.getDate() + 1);
const data: DataPoint[] = [
{ date: monday, value: 70 },
{ date: tuesday, value: 80 },
];
const result = weeklyAverages(data);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(75);
});
// v3 audit caught a TZ bug: a Sunday-evening Berlin reading (e.g. 22:00
// local = 21:00 UTC in summer / 21:00 UTC in winter via DST) bucketed
// into next week on UTC servers because `Date.getDay()` was system-local.
// The Berlin-TZ-aware implementation puts the same point into THIS week.
it("buckets Sunday-evening Berlin into the same ISO week as the preceding Monday", () => {
// 2024-08-04 is a Sunday; 22:00 Berlin = 20:00 UTC (CEST). The Monday
// of the same Berlin week is 2024-07-29.
const sundayEveningBerlin = new Date("2024-08-04T20:00:00.000Z");
const mondayMidday = new Date("2024-07-29T11:00:00.000Z");
const result = weeklyAverages([
{ date: mondayMidday, value: 70 },
{ date: sundayEveningBerlin, value: 90 },
]);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(80); // (70 + 90) / 2
expect(result[0].date.toISOString()).toBe("2024-07-29T00:00:00.000Z");
});
});
@@ -22,7 +22,7 @@ vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
vi.mock("@/lib/measurements/rollup-coverage", () => ({
vi.mock("@/lib/rollups/measurement-coverage", () => ({
isFullyCovered: vi.fn(),
probeRollupCoverage: vi.fn(),
}));
@@ -32,7 +32,7 @@ import { annotate } from "@/lib/logging/context";
import {
isFullyCovered,
probeRollupCoverage,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { computeUserHealthScoreFastPath } from "../health-score-fast-path";
const MEASUREMENT_FIND_MANY = prisma.measurement.findMany as unknown as ReturnType<typeof vi.fn>;
@@ -102,6 +102,11 @@ describe("computeUserHealthScoreFastPath", () => {
computedAt: now,
},
]);
// v1.4.40 W-WMY-WIRE — the parallel long-window read fires
// alongside the DAY-bucket read; mock its `findMany` to return
// a coverage miss so the annotate carries `weightLongWindow:
// null` and the score shape is unchanged.
ROLLUP_FIND_MANY.mockResolvedValueOnce([]);
// Weight source-attribution read (narrow 2-column).
MEASUREMENT_FIND_MANY
.mockResolvedValueOnce([
@@ -147,6 +152,170 @@ describe("computeUserHealthScoreFastPath", () => {
});
});
/**
* v1.4.40 W-WMY-WIRE — pin the long-window weight wiring through
* `readBestGranularityRollups`. The score shape is unchanged; the
* weightLongWindow annotate makes the rollup-tier consumption
* verifiable in production wide-events.
*/
describe("long-window weight wiring (W-WMY-WIRE)", () => {
it("surfaces a MONTH-granularity weightLongWindow mean on the rollup branch", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// DAY-bucket read (trailing 37 days) — single bucket, irrelevant
// for the long-window assertion.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 82.5,
minValue: 82.5,
maxValue: 82.5,
sd: null,
slope: null,
r2: null,
computedAt: now,
},
]);
// readBestGranularityRollups(WEIGHT, 365) — first call is the
// MONTH probe (floor 181 d). Two MONTH buckets, count-weighted
// mean = (12*82 + 18*80) / 30 = 80.8.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2025-08-01T00:00:00.000Z"),
count: 12,
mean: 82,
minValue: 80,
maxValue: 84,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
},
{
bucketStart: new Date("2025-09-01T00:00:00.000Z"),
count: 18,
mean: 80,
minValue: 78,
maxValue: 83,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
},
]);
// Source attribution + BP-SYS attribution reads.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
source: "WITHINGS",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-longwindow",
bpInTargetPct: 85,
heightCm: 178,
now,
coverage,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "rollup",
);
expect(pathCall).toBeDefined();
const longWindow = pathCall?.meta?.healthScore?.weightLongWindow;
expect(longWindow).not.toBeNull();
expect(longWindow?.granularity).toBe("MONTH");
expect(longWindow?.buckets).toBe(2);
// Σ(count*mean) / Σcount = (12*82 + 18*80) / 30 = 80.8 → rounded.
expect(longWindow?.mean).toBeCloseTo(80.8, 5);
});
it("emits weightLongWindow:null when the WMY tier carries no buckets", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", true]]);
FULLY_COVERED.mockReturnValue(true);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// DAY-bucket read.
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: new Date("2026-05-10T00:00:00.000Z"),
count: 1,
mean: 82.5,
minValue: 82.5,
maxValue: 82.5,
sd: null,
slope: null,
r2: null,
computedAt: now,
},
]);
// Long-window read — every granularity returns empty (router
// walks MONTH → WEEK → DAY; 365 d skips YEAR floor).
ROLLUP_FIND_MANY.mockResolvedValue([]);
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
source: "WITHINGS",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-no-longwindow",
bpInTargetPct: 85,
heightCm: 178,
now,
coverage,
});
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "rollup",
);
expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull();
});
it("omits the long-window read on the live fallback branch", async () => {
const coverage = new Map<string, boolean>([["WEIGHT", false]]);
FULLY_COVERED.mockReturnValue(false);
PROBE.mockResolvedValue(coverage);
const now = new Date("2026-05-17T12:00:00.000Z");
// Weight live read + BP source attribution.
MEASUREMENT_FIND_MANY.mockResolvedValueOnce([
{
measuredAt: new Date("2026-05-10T08:00:00.000Z"),
value: 82.5,
source: "MANUAL",
},
]).mockResolvedValueOnce([]);
await computeUserHealthScoreFastPath({
userId: "user-live-nowmy",
bpInTargetPct: 80,
heightCm: 178,
now,
coverage,
});
// The live branch never issues the long-window probe — guards
// against accidentally double-reading on cold mounts where the
// raw weight findMany is already doing the work.
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
const calls = ANNOTATE.mock.calls.map((c) => c[0]);
const pathCall = calls.find(
(c) => c?.meta?.healthScore?.path === "live",
);
expect(pathCall?.meta?.healthScore?.weightLongWindow).toBeNull();
});
});
describe("live fallback — WEIGHT not covered", () => {
it("reads raw weight rows and pins path:live", async () => {
const coverage = new Map<string, boolean>([
@@ -291,7 +291,164 @@ describe("computeSummariesSlice", () => {
// probe + narrow aggregate + latests + rollup GROUP BY
// (v1.4.37.2 — the prior `findMany` is gone). No heavy aggregate.
expect(RAW).toHaveBeenCalledTimes(4);
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(0);
// v1.4.40 W-WMY-WIRE — the year-ago baseline probe runs
// `readBestGranularityRollups(userId, type, 395)` per
// type-with-data via `prisma.measurementRollup.findMany`. The
// 395-day window skips the YEAR floor (731 d) and walks MONTH
// → WEEK → DAY on full coverage miss; the default mock returns
// `[]` so all three reachable tiers probe.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
});
/**
* v1.4.40 W-WMY-WIRE — pin the wiring of the WMY readers into the
* slim slice. The pre-v1.4.40 shape hardcoded `avg30LastYear` to
* `null`; we now populate it from `readBestGranularityRollups` when
* the YEAR / MONTH / WEEK / DAY tier carries buckets that overlap
* the `[now-395d, now-365d)` slice.
*/
describe("year-over-year wiring (avg30LastYear)", () => {
it("populates avg30LastYear from MONTH buckets that overlap the year-ago slice", async () => {
// Coverage probe shows WEIGHT covered → rollup happy path.
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Year-ago slice MONTH bucket — `bucketStart` placed 380 days
// ago so it falls inside `[now-395d, now-365d)`. Single bucket
// (count=10, mean=85) → weighted mean = 85.
const yearAgoBucketStart = new Date(
Date.now() - 380 * 24 * 60 * 60 * 1000,
);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: yearAgoBucketStart,
count: 10,
mean: 85,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 83,
maxValue: 87,
},
]);
const result = await computeSummariesSlice("user-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBe(85);
// Router asked MONTH first (395 > 181, 395 < 731 → skip YEAR).
expect(ROLLUP_FIND_MANY.mock.calls[0][0].where.granularity).toBe("MONTH");
});
it("leaves avg30LastYear null when no bucket overlaps the year-ago slice", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// MONTH bucket placed 30 days ago — inside the YEAR / MONTH /
// WEEK 395-day window the router asks for, but outside the
// `[now-395d, now-365d)` slice. The helper returns null.
const recentBucket = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
ROLLUP_FIND_MANY.mockResolvedValueOnce([
{
bucketStart: recentBucket,
count: 10,
mean: 82,
sd: 1,
slope: 0,
r2: 0,
sumValue: null,
minValue: 80,
maxValue: 84,
},
]);
const result = await computeSummariesSlice("user-recent");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
});
it("leaves avg30LastYear null when every granularity misses (no coverage)", async () => {
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: true }])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82,
avg30: 82.5,
slope7: 0,
r2_7: 0,
slope30: 0,
r2_30: 0,
slope90: 0,
r2_90: 0,
},
])
.mockResolvedValueOnce([
{ type: "WEIGHT", value: 82.7, measured_at: new Date() },
])
.mockResolvedValueOnce([
{ type: "WEIGHT", count: 5, min: 82, max: 84, mean: 83 },
]);
// Default mock returns `[]` for every findMany call → router
// walks YEAR → MONTH → WEEK → DAY and gives up.
ROLLUP_FIND_MANY.mockResolvedValue([]);
const result = await computeSummariesSlice("user-empty-yoy");
expect(result.summaries.WEIGHT.avg30LastYear).toBeNull();
// 395d window skips YEAR (floor 731 d) → MONTH → WEEK → DAY,
// three reachable tiers all probed on full coverage miss.
expect(ROLLUP_FIND_MANY).toHaveBeenCalledTimes(3);
});
it("only probes types that actually have data in the current window", async () => {
// Coverage probe + rollup GROUP BY both report zero rows → no
// types-with-data, so the year-ago probe must NOT fan out.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
await computeSummariesSlice("user-no-data");
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
});
});
@@ -1,6 +1,5 @@
import { describe, it, expect } from "vitest";
import {
movingAverage,
trendSlope,
detectAnomalies,
summarize,
@@ -15,27 +14,6 @@ function makePoints(values: number[], startDaysAgo = 30): DataPoint[] {
}));
}
describe("movingAverage", () => {
it("returns empty for empty input", () => {
expect(movingAverage([], 7)).toEqual([]);
});
it("calculates moving average over window", () => {
const data = makePoints([70, 71, 72, 73, 74, 75, 76], 7);
const result = movingAverage(data, 3);
expect(result).toHaveLength(7);
// Last point should be avg of last 3 days
expect(result[result.length - 1].value).toBeCloseTo(75, 0);
});
it("handles single data point", () => {
const data = makePoints([100], 1);
const result = movingAverage(data, 7);
expect(result).toHaveLength(1);
expect(result[0].value).toBe(100);
});
});
describe("trendSlope", () => {
it("returns null for insufficient data", () => {
expect(trendSlope([], 7)).toBeNull();
-35
View File
@@ -1,35 +0,0 @@
/**
* v1.4.22 W5 reconcile (Code-MED-1) — shared Berlin-day-key helper.
*
* The CLAUDE.md timezone convention is "Europe/Berlin for display,
* UTC in database". The dashboard analytics route used a private
* `berlinDayKey()` (defined in `src/app/api/analytics/route.ts:330`)
* to bucket measurements by Berlin calendar day. The targets route's
* `sparklinePoints()` was bucketing by UTC `YYYY-MM-DD` — a 23:30
* Berlin reading on Tuesday landed in Wednesday's bucket, drifting
* the sparkline 1 day on cross-DST boundaries.
*
* Lift the helper into `src/lib/analytics/` so every display surface
* shares the same bucket contract.
*/
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
});
/**
* Format a `Date` as a `YYYY-MM-DD` string anchored to Europe/Berlin
* calendar day. Used as a stable key for daily aggregation so a
* 23:30-Berlin reading on Tuesday lands in Tuesday's bucket
* regardless of UTC offset (CET / CEST, DST).
*/
export function berlinDayKey(d: Date): string {
const parts = BERLIN_DATE_PARTS.formatToParts(d);
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const day = parts.find((p) => p.type === "day")?.value ?? "01";
return `${y}-${m}-${day}`;
}
+3 -3
View File
@@ -52,11 +52,11 @@
*/
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { readRollupBuckets } from "@/lib/measurements/rollups";
import { readRollupBuckets } from "@/lib/rollups/measurement-rollups";
import {
probeRollupCoverage,
type RollupCoverageMap,
} from "@/lib/measurements/rollup-coverage";
} from "@/lib/rollups/measurement-coverage";
import { isNearUtc } from "@/lib/tz/resolver";
import {
computeBpInTargetWindows,
@@ -432,7 +432,7 @@ async function fetchSeriesChunked(
let cursorId: string | undefined;
for (let page = 0; page < 1000; page++) {
const chunk = await prisma.measurement.findMany({
where: { userId, type, measuredAt: { gte: since } },
where: { userId, type, measuredAt: { gte: since }, deletedAt: null },
orderBy: [{ measuredAt: "asc" }, { id: "asc" }],
select: { id: true, measuredAt: true, value: true },
take: CHUNK,
Loaded 100 of 146 files, more files were not shown because too many files have changed in this diff. Show more