chore(release): v1.4.49 — audit-backlog closure + server reminder suppression + diagnostic endpoint + analytics cold-path fix

Bundles the deferred v1.4.47 Mediums + Lows with the v1.4.48-deferred items
into a single release. Headline: server-side suppression of MEDICATION_REMINDER
APNs for iOS clients that own their local reminders (closes GH #206), a
push_attempts table backing /api/admin/notifications/diagnostic, and a sweep
of v1.4.48 QA forward-findings.

Performance (the user-visible win):
- /api/analytics cold-fallback path splits the heavy single-statement
  aggregate into two parallel queries. The linearly composable columns
  (count / min / max / mean) keep the full-partition scan; the windowed
  7 / 30 / 90-day avg / slope / r² columns take a 90-day outer cap so
  the planner does an index range scan on (user_id, type, measured_at)
  instead of a full-partition sequential scan. Output is bit-identical;
  cold slim slice drops from multi-second to sub-second on tenants with
  large measurement partitions.

Server-side notifications:
- GET + PATCH /api/auth/me/notification-prefs with deep-merge semantics.
  Initial category medication.clientManaged: boolean (default false). Cron
  medicationReminderJob skips dose-due APNs when the flag is true,
  emitting a medication_reminder.suppressed_client_managed wide-event
  annotation per skip.
- push_attempts table backs the diagnostic endpoint. Every APNS /
  Web-Push / Telegram / NTFY sender writes a fire-and-forget row.
  Daily 03:35 Europe/Berlin cleanup prunes rows older than 90 days.
- /api/admin/notifications/diagnostic — admin-only endpoint surfacing
  device APNs token presence (masked), channel config presence, and
  recent push attempts. Registered in OpenAPI.
- Per-channel APNs "Send test" button now fires with
  eventType: "MEDICATION_REMINDER" so the dispatcher's time-sensitive +
  priority-10 branch runs. A successful self-test now exercises the
  exact path production reminders take; an unsuccessful one points the
  user at iOS Settings → Time Sensitive Notifications rather than
  appearing to be a server-side bug.

Withings reliability:
- OAuth callback consumes the nonce atomically (delete-first with
  P2025 catch for the replay branch). The previous read-then-delete
  pattern allowed a race between two concurrent callbacks.
- ?reason=state redirect differentiated into csrf1 / replay / expired /
  cross_user so operators can tell mismatch types apart without DB
  shell access.
- connect endpoint rate-limited to 10/60s/user; row-create failures
  redirect to a consistent error surface.

Observability hardening:
- redactSensitiveFields(body) denylist helper covers password / token /
  secret / apiKey / authorization / csrfState / nonce, recursive,
  case-insensitive. Caught: /api/devices invalid_format issues were
  echoing apnsToken values verbatim into the audit log.
- sanitiseZodIssues(issues, { stripValuesFromMessage: true }) overload
  applied to 14 audit-log sites.

Migrations: 0079 (notification_prefs), 0080 (push_attempts).

Tests: 4815 → 5272 unit + integration. Workout-batch integration suite
that had been red on main since v1.4.45 (three release commits shipped
through it) now green again — updated to match v1.4.42 cross-source
dedup semantics.

Resolves PR #207.
This commit is contained in:
Marc-André Bombeck committed 2026-05-23 12:51:41 +02:00
1 parent 30ab2112db
commit f92e644154
87 files changed
+7939 -2950

No files matched your search

+130 -56
View File
@@ -1,5 +1,81 @@
# Changelog
## [1.4.49] — 2026-05-23 — Audit-backlog closure + server-side reminder suppression + diagnostic endpoint backed
v1.4.47 closed the H1 + Mediums punch list. v1.4.49 bundles the deferred v1.4.47 Mediums and Lows together with the v1.4.48-deferred items: server-side suppression of `MEDICATION_REMINDER` APNs for iOS clients that manage their own local reminders, a `push_attempts` table backing the diagnostic endpoint, the `/api/admin/notifications/diagnostic` OpenAPI entry, and a sweep of v1.4.48 QA forward-findings (Withings reason-tagging, observability PII hardening, MoodReminderCard auto-clear parity, simplifier dead-code, sub-locale copy gaps). The workout-batch integration suite that had been red on `main` for three releases turns green; the cold-mount analytics fallback path picks up the same 90-day outer cap that v1.4.47.1 shipped for the rollup-fresh path; iOS validation-failure audit rows carry the rejected payload shape so iOS serialiser drift can be chased from a single log line.
### Added
- `/api/admin/notifications/diagnostic` — admin-only endpoint that surfaces device APNs token presence (masked to an 8-char prefix + 8-char suffix), notification channel config presence, and recent push attempts for the calling account. Closes the DB-shell bottleneck that slowed the v1.4.47 APNs investigation. The endpoint is registered in `docs/api/openapi.yaml` so future iOS clients can codegen against it.
- `push_attempts` table backs the diagnostic endpoint's `recentPushAttempts` field. Every APNS / Web-Push / Telegram / NTFY sender writes a fire-and-forget row per attempt (channel + eventType + result + reason + createdAt). The diagnostic endpoint now returns the last 20 attempts ordered by recency. Daily cleanup cron at 03:35 Europe/Berlin prunes rows older than 90 days.
- `GET` + `PATCH /api/auth/me/notification-prefs` — per-user notification preferences with deep-merge semantics. Initial category `medication.clientManaged: boolean` (default `false`); future categories slot in without overwriting siblings. The cron `medicationReminderJob` skips dose-due APNs when the flag is `true`, emitting a `medication_reminder.suppressed_client_managed` wide-event annotation per skip. Other notification kinds (mood reminder, personal record, system alert) are unaffected. Closes the server side of GitHub issue #206.
- `redactSensitiveFields(body)` helper at `src/lib/observability/redact-payload.ts` with a denylist (`password` / `token` / `secret` / `apiKey` / `authorization` / `csrfState` / `nonce`, case-insensitive, recursive). Used by the wide-event `received_shape_excerpt` surface so future free-text routes adopting the pattern cannot leak credentials.
- Native sub-locale translations for `settings.about.tourReplay` and `tourReplayHint` (es / fr / it / pl), and for `settings.about.linksHeading` + `newerAvailable` across the same four sub-locales. Tightened `dashboard.dragHandleHint` copy in de + pl (was ~30 words on a `sr-only` paragraph; screen readers spent ~6 s reading it on focus).
- OpenAPI documentation for `GET` + `PATCH /api/auth/me/disable-coach`.
- Operations runbook notes: `attemptNumber` audit-row vs alert-signal semantics at `docs/ops/attempt-number-semantics.md`, and explicit migration-before-container ordering at `docs/ops/deploy.md` for manual / partial-deploy scenarios.
### Changed
- `/api/analytics` cold-fallback path splits the heavy single-statement aggregate into two parallel queries. The linearly composable columns (`count` / `min` / `max` / `mean`) keep the full-partition scan; the windowed 7 / 30 / 90-day `avg` / `slope` / `r²` columns take a 90-day outer cap so the planner does an index range scan on `(user_id, type, measured_at)` instead of a full-partition sequential scan. Output is bit-identical; cold slim slice drops from multi-second to sub-second on tenants with large measurement partitions.
- Dashboard reorder + onboarding tour helpers consolidated. Arrow-button reorder delegates to the same swap path as drag-and-drop. `restartOnboardingTour` extracted so About + Account sections share the surface. `useSortable` honours `prefers-reduced-motion`. Drag-handle `aria-describedby` hint gates on visible widget count so the empty-state cannot orphan the hint paragraph.
- Coach disable surface tightened. The status banner auto-clears after 3 s. `PATCH /api/auth/me/disable-coach` body migrated to Zod + `returnAllZodIssues` for the uniform 422 envelope. The `eslint-disable react-hooks/rules-of-hooks` line in `useDisableCoach` is replaced with a shared `useQueryClientMounted` helper that both `useFeatureFlags` and `useDisableCoach` consume.
- Withings OAuth callback consumes the nonce atomically. `delete` is issued first; the `P2025` catch handles the replay branch. The previous read-then-delete pattern allowed a theoretical race between two concurrent callbacks with the same nonce. Silent `.catch(() => {})` on delete failures now surface via a Wide-Event warning so a real infra failure reaches the audit trail.
- Withings callback `?reason=state` redirect is now differentiated into four distinct reason tags so operators can tell `csrf1` (URL/cookie mismatch), `replay` (nonce already consumed), `expired` (TTL elapsed), and `cross_user` (session/row userId mismatch) apart without DB-shell access. The delete-failure warning template interpolates `err.name` rather than `${err}` so Prisma error messages cannot echo offending values into the audit log.
- Withings `connect` endpoint rate-limited to 10 calls per 60 s per user (mirroring the disable-coach pattern); row-create failures redirect to the consistent error surface (`/settings/integrations?withings=error&reason=connect`) instead of bubbling a 500.
- `buildPayloadDiagnostic(body)` helper extracted to `src/lib/api-response.ts`. The dashboard widgets PUT route and the measurements series GET route now route their bodies through `redactSensitiveFields` first then call the shared helper — single source of truth for the iOS payload-diff wide-event shape, redaction layered in front composably.
- `sanitiseZodIssues(issues, { stripValuesFromMessage: true })` overload added; 14 audit-log sites that wrote `JSON.stringify({ issues })` to `details` now strip the `message` string to a `{ path, code }` pair. Most impactful catch: the `/api/devices` route's `invalid_format` issues were echoing `apnsToken` values verbatim into the audit log.
- `disableCoachBody` + `disableCoachData` Zod schemas collapsed into a single `disableCoachFlag` schema in the OpenAPI route table; `zod-openapi` emits the request + response pair from the one source.
- `yaml@2` emitter no longer sorts map entries during `pnpm openapi:generate`. Alphabetical sort had placed alias references before their anchors when `.meta()`-tagged sub-schemas appeared inside `z.array(...)`. Output stability is preserved by Zod's declaration-order guarantee.
- Worker boot emits a Wide-Event warning if any `integration_statuses` row still has a NULL per-kind counter after the v1.4.47 legacy-column drop. Closes the silent alert-ladder gap where such rows would alert two strikes later than they should.
- `MoodReminderCard` status banner auto-clears after 3 s — parity with `DisableCoachCard` (the existing docstring claimed parity that the implementation lacked).
- `mergeReorderIntoLayout` emits a dev-only `console.warn` when a reorder id has no matching widget in the layout. Statically unreachable today; defence-in-depth for the upcoming per-tile Suspense refactor that introduces dynamic widgets.
### Fixed
- Workout-batch integration tests (`workout-batch-create`, `workout-batch-race`) updated to match the v1.4.42 cross-source dedup semantics. The integration suite had been red on `main` since v1.4.45 (three release commits shipped through it). Suite is green again.
- Twelve stale "migration 0076" references in code comments corrected. `consecutiveFailures` sites read 0077; `disable-coach` sites read 0078. Comment-only sweep, no behavioural change.
- `docs/ops/attempt-number-semantics.md` referenced migration 0076 for the dropped `consecutiveFailures` column; the actual migration is 0077.
- Per-channel APNs "Send test" button (Settings → notification channels) now fires with `eventType: "MEDICATION_REMINDER"` so the dispatcher's `time-sensitive` interruption-level + `apns-priority: 10` branch runs. The previous `SYSTEM_ALERT` event-type used the dispatcher's default `active` level, which iOS may summarise into the Notification Center rather than presenting a lock-screen banner — leaving the user with no signal whether a real dose reminder will surface visibly. A successful test now exercises the exact path production reminders take. Title and body strings still read "Test notification" so the push cannot be mistaken for an actual scheduled dose.
### Performance
- Cold-fallback `/api/analytics` slim slice — `aggregates` query split so the windowed columns get a 90-day outer `WHERE` cap, matching the inner `FILTER` windows. Multi-second TTFB on cold mounts drops to sub-second on tenants with large measurement partitions. The `narrows` query received the same cap in v1.4.47.1.
### Security
- Withings `connect` rate-limited (per-user 10 / 60 s) to bound ledger-row growth from a misbehaving client.
- Coach `disable-coach` validation envelope unified through `returnAllZodIssues` for a uniform 422 shape.
### Observability
- `dashboard.widgets.validation-failed` and `measurements.series.validation-failed` audit rows now carry `received_keys`, a truncated `received_shape_excerpt` (256-char hard cap), and the sanitised Zod issues. The excerpt is generated through `redactSensitiveFields` so credential-shaped keys (password / token / secret / apiKey / authorization / csrfState / nonce) land as the literal `"[redacted]"` instead of their raw values. One log line per validation failure carries both the iOS-sent shape and the server's rejection reason, so iOS serialiser drift can be chased without DB-shell access.
- Push attempts persisted in `push_attempts` for every channel (APNS / Web-Push / Telegram / NTFY). The diagnostic endpoint reads the last 20 per user; the daily cron at 03:35 Europe/Berlin prunes attempts older than 90 days. Operators can now grep the wide-event `notifications.send` annotation OR query the table directly for an APNs incident triage.
- Withings callback redirect reasons emit matching `meta.reason` annotations (`csrf1` / `replay` / `expired` / `cross_user`) so the wide-event stream carries the same differentiation the URL query param does.
### Documentation
- `NEXT_PUBLIC_APP_VERSION`, `NEXT_PUBLIC_APP_BUILD_SHA`, `NEXT_PUBLIC_APP_BUILT_AT` documented as build-time-only env-vars in `docs/self-hosting/reverse-proxy.md`. The Coolify runtime entry was misleading; Next.js bakes the values into the bundle at build time via the `next.config.ts` `env:` block.
### Test hygiene
- New grep-discovery test mirrors the `flags.coach` discovery test for `user.disableCoach` / `useDisableCoach`. New sites must be added to the allowlist explicitly so CI fails on an unreviewed addition.
- `target-card.tsx` per-card "Ask the coach" CTA pinned in the user-disable fixture (the cascade fixture already covered the page-level gate).
- `textarea` `forwardRef` `Symbol.for("react.forward_ref")` sentinel assertion deleted (React-internal API). Ref forwarding is covered indirectly by the call sites that pass `ref` to `<Textarea>`.
- `vi.useRealTimers()` moved into the suite-level `afterEach` in the medications-intake and dashboard-summary route tests. A failed assertion no longer leaks fake timers across suites.
- New test files pin the v1.4.49 surfaces: `redact-payload.test.ts` (9 cases — passthrough / per-key redact / Authorization case-insensitive / nested recursion / array recursion / multi-pattern / scalar passthrough / Date preservation / pattern-set pin), `push-attempt-record.test.ts` (13 cases across the four senders × ok / error / skipped + DB-error-swallowed), `notification-prefs/route.test.ts` (11 cases), `notification-prefs.test.ts` validations (18 cases), `mood-reminder-card.test.tsx` (2 auto-clear cases).
- Full unit-test gate after v1.4.49 reconcile: 510 files / 5272 passed / 0 failed / 1 skipped.
### Migrations
- `0079_v1449_user_notification_prefs` — adds `users.notification_prefs` (jsonb, nullable). Shape: `{ medication: { clientManaged: boolean } }`; future categories slot in next to `medication`.
- `0080_v1449_push_attempts` — new `push_attempts` table with `(user_id, created_at DESC)` index. Cascade-delete on user removal. Retention via the daily cleanup cron.
### Deferred
- iOS v0.6.0.8 ships the Settings-side notification-permission re-trigger (`healthlog-iOS#10`), the in-app APNs diagnostic surface, and the `CFBundleShortVersionString` + UA build-number fixes — these depend on Marc's Xcode build cycle, not the server. The server-side suppression flag added in this release is opt-in (`clientManaged: false` by default) so existing users on the still-buggy v0.6.0.7 client see no behaviour change until they upgrade to v0.6.0.8 and the iOS client explicitly opts in via `PATCH /api/auth/me/notification-prefs`.
- Reactive `prefersReducedMotion()` hook — current helper at `src/lib/charts/reduced-motion.ts` is read-once at render; doesn't react to mid-session OS toggle. Twenty-one call sites would benefit from a `useSyncExternalStore`-backed hook. Scoped as a v1.4.50 single-purpose refactor.
- `handleReminderCheck` extraction — the cron handler in `src/lib/jobs/reminder-worker.ts` is a 260-line non-exported blob. The v1.4.49 suppression skip-path is pinned via the helper `isMedicationReminderClientManaged` rather than a direct integration test of the handler. Scoped as v1.4.50 hygiene.
## [1.4.47.6] — 2026-05-22 — APNs per-channel test endpoint wired
The Settings notification-status card's "Test senden" button was unwired for the APNS channel — the `TEST_ENDPOINTS` map at `notification-status-card.tsx:60-64` had TELEGRAM / NTFY / WEB_PUSH but not APNS. Clicking the button on an APNS row resolved to `undefined` and the fetch fired against an empty URL, silently failing. The operator had no first-class way to verify the v1.4.47.5 auto-detect path; only the natural channel-state-machine cron retry could exercise it.
@@ -185,9 +261,9 @@ Eight changes landed on `develop` before this release commit:
- **No API contract break for iOS v0.5.4.** `disableCoach` is additively extended in `/api/auth/me` (older iOS reads coerce `undefined → false`). Withings OAuth state cookie name preserved; in-flight handshakes survive a deploy. The Coach disable toggle is opt-in; default behaviour unchanged.
- **`pnpm test --run` green at ~5100+ passing**, `pnpm typecheck` + `pnpm lint` clean.
## [1.4.46] — 2026-05-22 — Server reconcile (PR worker cumulative-bucketing + intake auto-skip + APNS admin test)
## [1.4.46] — 2026-05-22 — Server follow-up (PR worker cumulative-bucketing + intake auto-skip + APNS admin test)
Discovery follow-up from the v1.4.45 marathon-close review surfaced three server gaps that the audit waves hadn't covered. v1.4.46 lands all three as touch-disjoint fixes.
Discovery follow-up from the v1.4.45 post-deploy review surfaced three server gaps that the audit hadn't covered. v1.4.46 lands all three as independent fixes.
### Fixed
@@ -201,74 +277,72 @@ Discovery follow-up from the v1.4.45 marathon-close review surfaced three server
## [1.4.45] — 2026-05-21 — Analytics 9 s perf fix, audit-driven polish, Zod multi-issue rollout, Withings parked-state automation
> Version note — v1.4.43 was skipped: v1.4.44 shipped as a same-day REG-11 iOS hotfix on `main` while this marathon was running (REG-11 = Home dashboard tile renders neither chart nor latest value when the most recent reading is older than 7 days; root cause was in `/api/dashboard/summary` SQL gates). The REG-11 fix is included in this release alongside the marathon work. v1.4.45 keeps the version monotone above the hotfix tag.
> Version note — v1.4.43 was skipped: v1.4.44 shipped as a same-day REG-11 iOS hotfix on `main` while this release work was running (REG-11 = Home dashboard tile renders neither chart nor latest value when the most recent reading is older than 7 days; root cause was in `/api/dashboard/summary` SQL gates). The REG-11 fix is included in this release alongside the rest of the closure work. v1.4.45 keeps the version monotone above the hotfix tag.
v1.4.42 closed the iOS-readiness story. v1.4.45 is the post-deploy discovery + closure release: a four-axis audit round (analytics perf / mobile-UI / QoL / security) surfaced one Critical `/api/analytics` 9 s regression that had been latent since v1.4.40, five High mobile-UI WCAG paper-cuts, two PII / log-growth gaps on the security surface, six High QoL copy + i18n gaps, and the chart empty-state false-positive raised after deploy. Eleven implementation waves landed: nine close every audit Critical + High plus the recurring polish items (chart-gate raw-count, QoL copy + plural forms, Withings classifier wiring across both sync paths, ops hardening including the BuildKit version-pin lesson from v1.4.42), one rolls out the v1.4.42 W2-ZOD `returnAllZodIssues` helper to the 41 sibling routes that still dropped every issue past the first, and one closes the v1.4.42 W2-WITHINGS deferred B4 (park after 24 h persistent-failure streak) + B7 (per-kind failure counters). Three companion waves close the audit Mediums + Lows for mobile-UI, QoL, and security.
v1.4.42 closed the iOS-readiness story. v1.4.45 is the post-deploy discovery + closure release: a four-axis audit round (analytics perf / mobile-UI / QoL / security) surfaced one Critical `/api/analytics` 9 s regression that had been latent since v1.4.40, five High mobile-UI WCAG paper-cuts, two PII / log-growth gaps on the security surface, six High QoL copy + i18n gaps, and the chart empty-state false-positive raised after deploy. Eleven separate fixes landed: nine close every audit Critical + High plus the recurring polish items (chart-gate raw-count, QoL copy + plural forms, Withings classifier wiring across both sync paths, ops hardening including the BuildKit version-pin lesson from v1.4.42), one rolls out the v1.4.42 `returnAllZodIssues` helper to the 41 sibling routes that still dropped every issue past the first, and one closes the v1.4.42 Withings classifier deferred items B4 (park after 24 h persistent-failure streak) + B7 (per-kind failure counters). Three companion sub-tasks close the audit Mediums + Lows for mobile-UI, QoL, and security.
Fourteen touch-disjoint waves landed on `develop` before this release commit. The wave reports under `.planning/phase-W*-v1443-report.md` document the per-wave decisions, file inventories, and test deltas; the multi-axis QA round under `.planning/round-v1443-QA-*-findings.md` covers the cross-cutting verdict that gated this tag; the audit reports under `.planning/round-v1443-AUDIT-*-findings.md` document the discovery phase that fed the marathon.
Fourteen independent sub-tasks landed on `develop` before this release commit.
### Added
- **`returnAllZodIssues` rolled out across 41 API routes** (W6) — every measurements / medications / mood / auth / settings / admin / consent / bugreport / feedback / device / token / ingest route now returns every Zod issue under `details.issues` plus a sanitised `{ path, code, message }` projection. iOS-contract hot paths additionally write a `<route>.validation-failed` audit-ledger breadcrumb (fire-and-forget). The CSV-import outlier (`/api/medications/[id]/intake/import`) preserves its `Invalid format:` prefix via `meta.errorCode = "medication.intake.import.invalid_format"`. 220 new unit cases pin 2-issue / 3-issue scenarios per touched route.
- **Withings persistent-failure park automation** (W14) — a `persistent` failure streak running > 24 h flips `IntegrationStatus.state` to a new `parked` sentinel. Settings card renders a pill `data-state="parked"` with copy "Pausiert — manuell wieder verbinden" (DE) / "Paused — reconnect manually" (EN) plus a "Wieder verbinden" / "Reconnect" CTA. New endpoint `POST /api/integrations/withings/resume` (rate-limited 5/min/user, idempotent) clears the park via `resumeIntegrationFromPark` and the per-card status query invalidates so the pill flips back without a refresh. Schema migration `0075_v1443_integration_park` adds the `consecutive_failures_by_kind` JSONB column + `persistent_failure_started_at` timestamp; backfill on next write bucketises the legacy single-counter into the current `FailureKind`.
- **`POST /api/auth/check-user` audit-ledger breadcrumb** (W13 M-1) — every branch (`found` / `not_found` / `passkey_only` / `email_fallback`) now writes `auditLog("auth.check-user", { ipAddress, details: { branch, identifier_hash } })` with the identifier hashed via `hashToken`. The route's per-IP throttle (30/15 min) stays; the audit row closes the operator-grep gap for enumeration-attempt investigations.
- **`/api/settings/account` DELETE cascade** (W12 M3) — split the danger-zone "Konto vollständig löschen" from the existing "Alle Gesundheitsdaten löschen" so a user reading "Gefahrenzone" reaches the right destructive action. New endpoint cascades User + sessions + passkeys + audit log under a Prisma tx; concurrent active sessions are invalidated synchronously. Six new integration tests pin the happy path + sibling-session invalidation + 401 / 422 / last-admin guard.
- **`<OfflineBanner>` mounted in `<AuthShell>`** (W12 M5) — listens for `online` / `offline` window events and renders "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist". Bilingual via i18n. Closes the PWA "blank tile + no explanation when airplane-mode toggles mid-form-fill" paper-cut.
- **`<ChartSkeleton>` 3 s "still computing" caption** (W12 L4) — after 3 s of skeleton paint a small caption renders to set user expectation when `/api/analytics` is on the slow path. Bilingual.
- **`<AccountDeleteCard>` + neutral danger-zone visuals** (W12 M3 + L5) — `AlertTriangle` icon dropped, title in neutral grey, button stays red. Same protective gate, less visual hostility.
- **`formatDateOrRelative(iso)` helper** (W12 L8) — within 24 h renders relative ("vor 12 min"), older renders absolute. Wired into `measurement-list.tsx:572,717` so adjacent timestamps no longer mix formats.
- **`scrollBehaviorForUser()` helper in `src/lib/motion.ts`** (W5 H5) — reads `prefers-reduced-motion: reduce` and returns `"auto"` or `"smooth"`. Wired into the four JS-driven smooth-scroll sites (`settings-shell`, `admin-shell`, `coach-panel/message-thread` × 2).
- **`charts.noDataInRangeTitle` + `charts.noDataInRangeDescription`** copy across all six locales (W11 M6). Replaces the silent `return null` branch in `<HealthChart>` with a proper `<ChartEmptyState>` so the dashboard layout doesn't reflow when a chart enters its empty branch.
- **`charts.needMoreDistinctDaysTitle` + `charts.needMoreDistinctDaysDescription`** copy across all six locales (W2 C1). Distinguishes the "few raw measurements" branch from the "lots of data, only on 1-2 days" branch on the chart's empty state.
- **`<DailyBriefing>` / `<CorrelationRow>` / `<TrendsRow>` skeleton heights reserved** to match loaded content (W11 M3) — `h-[24rem]` for DailyBriefing, `h-[20rem]` for the others. Closes the CLS hot-spot on cold mounts.
- **`<DashboardTileStripSkeleton>`** painted when `trendCards.length === 0 && analyticsSlimQuery.isLoading && layout.widgets.some(w => w.tileVisible)` (W11 M5). User sees the strip silhouette during the slow window instead of header + empty page + 9 s jump.
- **`returnAllZodIssues` rolled out across 41 API routes** — every measurements / medications / mood / auth / settings / admin / consent / bugreport / feedback / device / token / ingest route now returns every Zod issue under `details.issues` plus a sanitised `{ path, code, message }` projection. iOS-contract hot paths additionally write a `<route>.validation-failed` audit-ledger breadcrumb (fire-and-forget). The CSV-import outlier (`/api/medications/[id]/intake/import`) preserves its `Invalid format:` prefix via `meta.errorCode = "medication.intake.import.invalid_format"`. 220 new unit cases pin 2-issue / 3-issue scenarios per touched route.
- **Withings persistent-failure park automation** — a `persistent` failure streak running > 24 h flips `IntegrationStatus.state` to a new `parked` sentinel. Settings card renders a pill `data-state="parked"` with copy "Pausiert — manuell wieder verbinden" (DE) / "Paused — reconnect manually" (EN) plus a "Wieder verbinden" / "Reconnect" CTA. New endpoint `POST /api/integrations/withings/resume` (rate-limited 5/min/user, idempotent) clears the park via `resumeIntegrationFromPark` and the per-card status query invalidates so the pill flips back without a refresh. Schema migration `0075_v1443_integration_park` adds the `consecutive_failures_by_kind` JSONB column + `persistent_failure_started_at` timestamp; backfill on next write bucketises the legacy single-counter into the current `FailureKind`.
- **`POST /api/auth/check-user` audit-ledger breadcrumb** — every branch (`found` / `not_found` / `passkey_only` / `email_fallback`) now writes `auditLog("auth.check-user", { ipAddress, details: { branch, identifier_hash } })` with the identifier hashed via `hashToken`. The route's per-IP throttle (30/15 min) stays; the audit row closes the operator-grep gap for enumeration-attempt investigations.
- **`/api/settings/account` DELETE cascade** — split the danger-zone "Konto vollständig löschen" from the existing "Alle Gesundheitsdaten löschen" so a user reading "Gefahrenzone" reaches the right destructive action. New endpoint cascades User + sessions + passkeys + audit log under a Prisma tx; concurrent active sessions are invalidated synchronously. Six new integration tests pin the happy path + sibling-session invalidation + 401 / 422 / last-admin guard.
- **`<OfflineBanner>` mounted in `<AuthShell>`** — listens for `online` / `offline` window events and renders "Keine Verbindung — Änderungen werden gespeichert, sobald du wieder online bist". Bilingual via i18n. Closes the PWA "blank tile + no explanation when airplane-mode toggles mid-form-fill" paper-cut.
- **`<ChartSkeleton>` 3 s "still computing" caption** — after 3 s of skeleton paint a small caption renders to set user expectation when `/api/analytics` is on the slow path. Bilingual.
- **`<AccountDeleteCard>` + neutral danger-zone visuals** — `AlertTriangle` icon dropped, title in neutral grey, button stays red. Same protective gate, less visual hostility.
- **`formatDateOrRelative(iso)` helper** — within 24 h renders relative ("vor 12 min"), older renders absolute. Wired into `measurement-list.tsx:572,717` so adjacent timestamps no longer mix formats.
- **`scrollBehaviorForUser()` helper in `src/lib/motion.ts`** — reads `prefers-reduced-motion: reduce` and returns `"auto"` or `"smooth"`. Wired into the four JS-driven smooth-scroll sites (`settings-shell`, `admin-shell`, `coach-panel/message-thread` × 2).
- **`charts.noDataInRangeTitle` + `charts.noDataInRangeDescription`** copy across all six locales. Replaces the silent `return null` branch in `<HealthChart>` with a proper `<ChartEmptyState>` so the dashboard layout doesn't reflow when a chart enters its empty branch.
- **`charts.needMoreDistinctDaysTitle` + `charts.needMoreDistinctDaysDescription`** copy across all six locales. Distinguishes the "few raw measurements" branch from the "lots of data, only on 1-2 days" branch on the chart's empty state.
- **`<DailyBriefing>` / `<CorrelationRow>` / `<TrendsRow>` skeleton heights reserved** to match loaded content — `h-[24rem]` for DailyBriefing, `h-[20rem]` for the others. Closes the CLS hot-spot on cold mounts.
- **`<DashboardTileStripSkeleton>`** painted when `trendCards.length === 0 && analyticsSlimQuery.isLoading && layout.widgets.some(w => w.tileVisible)`. User sees the strip silhouette during the slow window instead of header + empty page + 9 s jump.
### Changed
- **`/api/analytics` slim slice `computeAvg30LastYearMap` capped at `p-limit(4)`** (W1) — closes the 9 s regression introduced in v1.4.40 W-WMY-WIRE. The unbounded `Promise.all` over 15 measurement types drowned the `pg.Pool` max=20 even after v1.4.40's W-POOL raise; capping at 4 collapses the burst to match the thick route's existing `ANALYTICS_TYPE_FETCH_CONCURRENCY=4`. Expected dashboard cold-mount: 9.0 s → 2-3 s (concurrent slim + thick both cold) / ~50 ms (warm cache); WMY-converged tenants ≤ 500 ms cold. The slim slice's per-(userId, date) cache key is unchanged; concurrent dashboards still single-flight per slice.
- **Withings activity + sleep sync catch-blocks routed through `WithingsApiError` typed classifier** (W7) — both `sync-activity.ts:174` and `sync-sleep.ts:155` previously caught raw thrown values + branched on `Error.message` string-matching. Now they typed-cast through `WithingsApiError` so a non-Error rejection lands in the `unknown` branch instead of silently sliding into `error_transient`. A typed-classification regression suite (`src/lib/withings/__tests__/sync-typed-classification.test.ts`) pins both paths.
- **`IntegrationStatusPill` warning state for `persistent` failures** (W4 H3) — v1.4.42's classifier introduced the fourth FailureKind but mapped it to `error_transient` so the pill read "Fehler — neu verbinden" identically to a transient failure. New "warning" pill state with copy "Verbunden, aber Serverfehler" gives the operator + user the signal that the access token still works but the upstream is responding with `601` / `293` / `294`.
- **`auth.check-user` rate-limit wrapper + 6 auth routes converted** (W13 M-4) — `checkAuthSurfaceRateLimit` wraps `checkRateLimit` and routes anonymous-bucket trust-violations to a tight 100/15-min global bucket instead of every misconfigured anonymous request collapsing into one shared `unknown` bucket. Applied to `/api/auth/login`, `/api/auth/register`, `/api/auth/passkey/login-options`, `/api/auth/passkey/login-verify`, `/api/auth/refresh`, `/api/auth/check-user`, `/api/auth/password`.
- **Coach SSE re-detects refusal on replayed turns** (W13 M-3) — every prior turn loaded from `coach_messages.encryptedContent` now runs through `detectRefusal` before re-entering the prompt. A positive hit short-circuits the SSE with a refusal response + emits an `audit.coach.replay-injection` audit row. Closes the "false-negative amplification" path where a regex-bank miss on turn N kept re-entering on every subsequent turn.
- **`/api/auth/passkey/login-verify` body now Zod-narrowed** (W13 L-3) — replaces the raw `as AuthenticationResponseJSON` cast with an explicit Zod parse upstream of `verifyAuthentication`. Closes the type-narrowing gap a future refactor could trip on; the verifier remains the runtime owner.
- **`activeLocale()` reads the full `Locale` union** (W12 M8) — sub-locale users (fr / es / it / pl) now get their native number + date formats from `Intl.DateTimeFormat` instead of the `en` fallback. The legacy `format.ts` helper backed ~25 SSR + audit-log call sites; the new branch falls back to `en` only when the cookie is unrecognised.
- **404 + global-error copy tightened across all locales** (W12 L1) — "Page not found / The page you were looking for doesn't exist or has been moved." → "Diese Seite existiert nicht." / "This page doesn't exist." period. Bilingual lockup retained on `global-error.tsx` (root layout has failed by the time this paints — no i18n provider).
- **`relativeMinutesAgo` / `relativeHoursAgo` / `relativeDaysAgo` keys split into `…One` / `…Other`** (W4 H6) — `count === 1` now renders the singular form across all six locales. The integration pill abbreviated forms (`vor 1 min` / `vor 1 d`) remain.
- **`coach.network` mapped to its own `errorNetwork` i18n key** (W12 M6) — distinct copy from the provider-error bucket so a user sees "Keine Internetverbindung" when their network is dead vs the original generic provider-unreachable copy when the provider itself is down.
- **`DayDrillDown` error renders `measurements.loadError` instead of `measurements.saveError`** (W4 H1) — closes the "Save error" misattribution on GET-only failures.
- **Switch tap target extends to 44 × 44 via `::before` pseudo-element** (W5 H1) — every Settings / sources / dashboard-layout / coach-settings / notification toggle now meets WCAG 2.5.5 without changing the visual.
- **Comparison-baseline buttons in chart overlay popover bump to 44 px on mobile** (W5 H2) — `min-h-11 sm:min-h-9` so phones get the safer target without densifying desktop.
- **Mood-form kebab + edit-mode footer kebab bump to 44 × 44** (W5 H3) — `h-11 w-11` to match the medication-form pattern.
- **Bottom-sheet close-X reaches 44 px on phones, keeps 36 px on desktop** (W5 H4) — `min-h-9 sm:min-h-11` gated on breakpoint.
- **`Loader2 animate-spin` paired with `motion-reduce:animate-none` across 21 sites** (W11 M1) — closes the motion-sensitivity gap on chart loading + settings spinners + admin backup runners + drug-level + doctor-report dialogs.
- **`phase-config-dialog.tsx` Input + Button bump to 44 px on mobile** (W11 M2) — drops `text-sm` from the Input so the iOS-zoom-on-focus is closed.
- **`correlation-card.tsx` skeleton mirrors the loaded scatter chart's aspect ratio** (W11 M4) — `aspect-square sm:aspect-[3/2] min-h-[180px] sm:h-auto`. Closes the ~60 px CLS on the insights cold mount.
- **`insights-tab-strip.tsx` group-popover items bumped to 44 px** (W11 L1) — outer tab pills already met the floor; inner popover items lagged.
- **`chart-overlay-controls.tsx` trigger adopts the responsive `min-h-11 sm:min-h-9` pattern** (W11 L2) — consistent with the v1.4.33 maintainer-item-7 settlement.
- **`RecentWorkoutsTile` + `DrugLevelChart` reserve loaded-card height during loading** (W11 L6) — no card-pop on first paint.
- **`dedupeWorkoutBatch` honours `User.sourcePriorityJson`** (W9) — write-time picker threads the same priority ladder the read-time picker uses. The v1.4.42 wave's docstring warning ("does NOT consult sourcePriorityJson") is now obsolete; the batch route does a single `prisma.user.findUnique` lookup before the dedup pass.
- **`/api/dashboard/widgets` 422 audit-ledger row dedupes per `(userId, action)` over 60 s** (W8 B2) — a misconfigured iOS client retrying every 100 ms no longer writes 10 audit rows per second. The 422 response is still per-call; only the audit-ledger row dedupes.
- **`/api/analytics` slim slice `computeAvg30LastYearMap` capped at `p-limit(4)`** — closes the 9 s regression introduced in v1.4.40. The unbounded `Promise.all` over 15 measurement types drowned the `pg.Pool` max=20 even after v1.4.40's pool raise; capping at 4 collapses the burst to match the thick route's existing `ANALYTICS_TYPE_FETCH_CONCURRENCY=4`. Expected dashboard cold-mount: 9.0 s → 2-3 s (concurrent slim + thick both cold) / ~50 ms (warm cache); WMY-converged tenants ≤ 500 ms cold. The slim slice's per-(userId, date) cache key is unchanged; concurrent dashboards still single-flight per slice.
- **Withings activity + sleep sync catch-blocks routed through `WithingsApiError` typed classifier** — both `sync-activity.ts:174` and `sync-sleep.ts:155` previously caught raw thrown values + branched on `Error.message` string-matching. Now they typed-cast through `WithingsApiError` so a non-Error rejection lands in the `unknown` branch instead of silently sliding into `error_transient`. A typed-classification regression suite (`src/lib/withings/__tests__/sync-typed-classification.test.ts`) pins both paths.
- **`IntegrationStatusPill` warning state for `persistent` failures** — v1.4.42's classifier introduced the fourth FailureKind but mapped it to `error_transient` so the pill read "Fehler — neu verbinden" identically to a transient failure. New "warning" pill state with copy "Verbunden, aber Serverfehler" gives the operator + user the signal that the access token still works but the upstream is responding with `601` / `293` / `294`.
- **`auth.check-user` rate-limit wrapper + 6 auth routes converted** — `checkAuthSurfaceRateLimit` wraps `checkRateLimit` and routes anonymous-bucket trust-violations to a tight 100/15-min global bucket instead of every misconfigured anonymous request collapsing into one shared `unknown` bucket. Applied to `/api/auth/login`, `/api/auth/register`, `/api/auth/passkey/login-options`, `/api/auth/passkey/login-verify`, `/api/auth/refresh`, `/api/auth/check-user`, `/api/auth/password`.
- **Coach SSE re-detects refusal on replayed turns** — every prior turn loaded from `coach_messages.encryptedContent` now runs through `detectRefusal` before re-entering the prompt. A positive hit short-circuits the SSE with a refusal response + emits an `audit.coach.replay-injection` audit row. Closes the "false-negative amplification" path where a regex-bank miss on turn N kept re-entering on every subsequent turn.
- **`/api/auth/passkey/login-verify` body now Zod-narrowed** — replaces the raw `as AuthenticationResponseJSON` cast with an explicit Zod parse upstream of `verifyAuthentication`. Closes the type-narrowing gap a future refactor could trip on; the verifier remains the runtime owner.
- **`activeLocale()` reads the full `Locale` union** — sub-locale users (fr / es / it / pl) now get their native number + date formats from `Intl.DateTimeFormat` instead of the `en` fallback. The legacy `format.ts` helper backed ~25 SSR + audit-log call sites; the new branch falls back to `en` only when the cookie is unrecognised.
- **404 + global-error copy tightened across all locales** — "Page not found / The page you were looking for doesn't exist or has been moved." → "Diese Seite existiert nicht." / "This page doesn't exist." period. Bilingual lockup retained on `global-error.tsx` (root layout has failed by the time this paints — no i18n provider).
- **`relativeMinutesAgo` / `relativeHoursAgo` / `relativeDaysAgo` keys split into `…One` / `…Other`** — `count === 1` now renders the singular form across all six locales. The integration pill abbreviated forms (`vor 1 min` / `vor 1 d`) remain.
- **`coach.network` mapped to its own `errorNetwork` i18n key** — distinct copy from the provider-error bucket so a user sees "Keine Internetverbindung" when their network is dead vs the original generic provider-unreachable copy when the provider itself is down.
- **`DayDrillDown` error renders `measurements.loadError` instead of `measurements.saveError`** — closes the "Save error" misattribution on GET-only failures.
- **Switch tap target extends to 44 × 44 via `::before` pseudo-element** — every Settings / sources / dashboard-layout / coach-settings / notification toggle now meets WCAG 2.5.5 without changing the visual.
- **Comparison-baseline buttons in chart overlay popover bump to 44 px on mobile** — `min-h-11 sm:min-h-9` so phones get the safer target without densifying desktop.
- **Mood-form kebab + edit-mode footer kebab bump to 44 × 44** — `h-11 w-11` to match the medication-form pattern.
- **Bottom-sheet close-X reaches 44 px on phones, keeps 36 px on desktop** — `min-h-9 sm:min-h-11` gated on breakpoint.
- **`Loader2 animate-spin` paired with `motion-reduce:animate-none` across 21 sites** — closes the motion-sensitivity gap on chart loading + settings spinners + admin backup runners + drug-level + doctor-report dialogs.
- **`phase-config-dialog.tsx` Input + Button bump to 44 px on mobile** — drops `text-sm` from the Input so the iOS-zoom-on-focus is closed.
- **`correlation-card.tsx` skeleton mirrors the loaded scatter chart's aspect ratio** — `aspect-square sm:aspect-[3/2] min-h-[180px] sm:h-auto`. Closes the ~60 px CLS on the insights cold mount.
- **`insights-tab-strip.tsx` group-popover items bumped to 44 px** — outer tab pills already met the floor; inner popover items lagged.
- **`chart-overlay-controls.tsx` trigger adopts the responsive `min-h-11 sm:min-h-9` pattern** — consistent with the v1.4.33 maintainer-item-7 settlement.
- **`RecentWorkoutsTile` + `DrugLevelChart` reserve loaded-card height during loading** — no card-pop on first paint.
- **`dedupeWorkoutBatch` honours `User.sourcePriorityJson`** — write-time picker threads the same priority ladder the read-time picker uses. The v1.4.42 docstring warning ("does NOT consult sourcePriorityJson") is now obsolete; the batch route does a single `prisma.user.findUnique` lookup before the dedup pass.
- **`/api/dashboard/widgets` 422 audit-ledger row dedupes per `(userId, action)` over 60 s** — a misconfigured iOS client retrying every 100 ms no longer writes 10 audit rows per second. The 422 response is still per-call; only the audit-ledger row dedupes.
### Fixed
- **`auth.login.failed` audit row no longer persists the raw typed identifier** (W3 H-1) — replaced with `identifier_hash: hashToken(identifier)`. Closes the PII gap surfaced by the v1.4.20 retroactive directive: an admin scrolling `/api/admin/audit-log` no longer sees every wrong-email a user typo'd at the login screen, and a future audit-table compromise no longer hands an attacker a list of probed identifiers.
- **`WithingsApiError.message` capped at 1024 chars in the constructor** (W3 H-2) — the v1.4.42 classifier persisted the upstream `json.error` string verbatim into `AuditLog.details`. Withings is the trusted upstream but the operator-readable audit row should match the encrypted-error column's 1024-char cap.
- **Chart empty-state copy split on raw measurement count** (W2 C1) — `<HealthChart>`, `<MoodChart>`, `<MedicationComplianceChart>` now distinguish "fewer than 3 entries" (empty-state title) from "many entries but only on 1-2 distinct days" (new `needMoreDistinctDays` title). The user-reported "Erfasse 3 Einträge" on a populated account is closed.
- **Chart `aria-busy` empty branch no longer silently returns null** (W11 M6) — replaced with a `<ChartEmptyState>` rendering the `noDataInRange` copy.
- **`docker-publish.yml` bakes the build-tag into the image at build time** (W8 B11) — closes the BuildKit cache version-stale paper-cut from v1.4.42.
- **`pnpm check-env` enforces manifest ↔ `.env.production.example` lockstep on CI** (W8 B5).
- **`global-error.tsx` bilingual lockup** (W4 H5 + W12 L1 follow-up).
- **`not-found.tsx` localised via `getServerTranslations`** (W4 H4).
- **German `daysAgo` integration-pill abbreviation** (W12 M7) — "vor {count} T." → "vor {count} d" matches the rest of the app's stale-hint pattern.
- **SW `CACHE_VERSION` re-anchors per build** (W12 L3) — `public/sw.js`'s literal `"v1.4.38.4"` had drifted four releases stale; the activate-step's old-cache eviction had been a no-op. A new `prebuild` script writes `public/sw-version.js` from `package.json`'s version + `importScripts('/sw-version.js')` re-anchors `CACHE_VERSION` per deploy.
- **Onboarding checklist measurement-completion copy** (W12 L2).
- **Doctor-report unavailable sections render disabled instead of vanishing** (W12 M4).
- **`auth/login + auth/register` test mocks updated for `checkAuthSurfaceRateLimit`** (post-cherry-pick reconcile) — W6's new test files pinned the old `checkRateLimit` signature; W13's M-4 wrapper rename collided at runtime causing 500 instead of 401 / 422. Both factories + `beforeEach` arming updated.
- **`auth.login.failed` audit row no longer persists the raw typed identifier** — replaced with `identifier_hash: hashToken(identifier)`. Closes the PII gap surfaced by the v1.4.20 retroactive directive: an admin scrolling `/api/admin/audit-log` no longer sees every wrong-email a user typo'd at the login screen, and a future audit-table compromise no longer hands an attacker a list of probed identifiers.
- **`WithingsApiError.message` capped at 1024 chars in the constructor** — the v1.4.42 classifier persisted the upstream `json.error` string verbatim into `AuditLog.details`. Withings is the trusted upstream but the operator-readable audit row should match the encrypted-error column's 1024-char cap.
- **Chart empty-state copy split on raw measurement count** — `<HealthChart>`, `<MoodChart>`, `<MedicationComplianceChart>` now distinguish "fewer than 3 entries" (empty-state title) from "many entries but only on 1-2 distinct days" (new `needMoreDistinctDays` title). The user-reported "Erfasse 3 Einträge" on a populated account is closed.
- **Chart `aria-busy` empty branch no longer silently returns null** — replaced with a `<ChartEmptyState>` rendering the `noDataInRange` copy.
- **`docker-publish.yml` bakes the build-tag into the image at build time** — closes the BuildKit cache version-stale paper-cut from v1.4.42.
- **`pnpm check-env` enforces manifest ↔ `.env.production.example` lockstep on CI**.
- **`global-error.tsx` bilingual lockup**.
- **`not-found.tsx` localised via `getServerTranslations`**.
- **German `daysAgo` integration-pill abbreviation** — "vor {count} T." → "vor {count} d" matches the rest of the app's stale-hint pattern.
- **SW `CACHE_VERSION` re-anchors per build** — `public/sw.js`'s literal `"v1.4.38.4"` had drifted four releases stale; the activate-step's old-cache eviction had been a no-op. A new `prebuild` script writes `public/sw-version.js` from `package.json`'s version + `importScripts('/sw-version.js')` re-anchors `CACHE_VERSION` per deploy.
- **Onboarding checklist measurement-completion copy**.
- **Doctor-report unavailable sections render disabled instead of vanishing**.
- **`auth/login + auth/register` test mocks updated for `checkAuthSurfaceRateLimit`** — the earlier new test files pinned the old `checkRateLimit` signature; the `checkAuthSurfaceRateLimit` wrapper rename collided at runtime causing 500 instead of 401 / 422. Both factories + `beforeEach` arming updated.
### Performance
Anchored on the v1.4.43 W1-ANALYTICS-PERF audit (`.planning/round-v1443-AUDIT-analytics-9s-findings.md`).
- **Dashboard cold-mount `/api/analytics` (concurrent slim + thick):** 9.0 s + 9.0 s in parallel → est. 2-3 s + 2-3 s in parallel (slim slice fan-out capped at `p-limit(4)`). Warm cache (60 s TTL hit) unchanged at ~50 ms. WMY-converged tenants ≤ 500 ms cold.
### Operator notes
+2760 -2527
View File
File diff suppressed because it is too large. Load diff
+52
View File
@@ -0,0 +1,52 @@
# `attemptNumber` audit-row vs alert-signal semantics
Two numbers describe a failing integration sync, and they can diverge.
On-call operators reading an audit-log row alongside an admin alert
should expect the alert number to be smaller than (or equal to) the
audit number, never the other way around.
## The two numbers
`integrations.sync.failed` audit rows write
`attemptNumber = transient + reauth_required + persistent` — the **sum**
of all three failure buckets. This is the historical "consecutive
failures" running total carried by the legacy
`IntegrationStatus.consecutiveFailures` column (dropped in migration
`0077`, v1.4.47). Auditors looking at a single row see one integer
that captures how many failures the bucket ledger has accumulated since
the last success.
The persistent-failure alert dispatcher in
`src/lib/integrations/status.ts:453` uses `Math.max(...buckets)` — the
**max** across the three buckets. A 3-deep persistent streak pages even
when the transient bucket is empty, and a flapping integration that
ping-pongs between transient and reauth_required never trips a
single-bucket threshold even if its sum looks alarming.
## When the two diverge
A typed-failure-streak edge case: integration fails 2× transient, then
2× reauth_required, then 1× persistent. Audit row shows
`attemptNumber: 5`. Alert signal is `max(2, 2, 1) = 2`, which is below
the default threshold of 3, so no page fires. From the audit log this
looks like a sustained 5-failure outage; from the alert dispatcher it
looks like three small bumps that never crossed a single-bucket
ceiling.
Both readings are correct for their purpose. The audit log records what
happened end-to-end; the alert dispatcher only fires when a single
failure mode persists, because mixed-bucket streaks usually indicate a
transient network blip layered on top of an unrelated reauth prompt —
two problems, neither of which is the "this integration is broken" page
we want to wake someone up for.
## Runbook implication
If you see an audit row with `attemptNumber >= threshold` but no alert
was dispatched, check the per-bucket counts on
`IntegrationStatus.failureBuckets` for that user/integration pair. The
sum may be high while every individual bucket sits below the threshold.
This is working as designed; manually investigate only if a single
bucket is at or above the threshold and `alertedAt` is unset (broken
dispatcher) or set within the last 24 hours (rate-limited, see the
`ALERT_REPEAT_WINDOW_MS` constant).
+62
View File
@@ -0,0 +1,62 @@
# Deploy ordering
Coolify handles the standard deploy ordering automatically: the
container image is pulled, `docker-entrypoint.sh` runs
`prisma migrate deploy` against the live database, and only then does
the Next.js process bind to the listener. This page documents the
ordering explicitly for manual-rollback and partial-deploy scenarios
where the operator drives the steps by hand.
## Migration ordering
Migrations land **before** the new app container takes traffic. The
canonical command sequence on a single-instance host:
```sh
# 1. Pull the new image.
docker pull ghcr.io/marc-bombeck/healthlog:vX.Y.Z
# 2. Run migrations against the live DB using the new image.
docker run --rm --env-file .env.production \
ghcr.io/marc-bombeck/healthlog:vX.Y.Z \
pnpm prisma migrate deploy
# 3. Recreate the app container so it picks up the new image.
docker compose up -d app
```
The entrypoint also runs `prisma migrate deploy` on container boot, so
step 2 is redundant in normal Coolify flow. It's listed explicitly for
the manual case so an operator can verify the migration result before
flipping traffic.
### What goes wrong if you reverse it
If the new app container starts before migrations run, the legacy
schema is missing the columns / indexes / enum values the new code
expects. Symptoms: Prisma client throws `P2022 column does not exist`
on the first query that touches the new shape, the request path 500s,
and the dispatcher logs fill with the same error until the migration
catches up. Health checks usually keep the container marked "healthy"
because `/api/version` doesn't exercise the affected tables — so a
naïve load balancer will happily route real traffic into the broken
shell.
### Rollback
Rolling back **migrations** is generally one-way. Prisma's migration
history table tracks applied migrations by hash; the `down` step is
not auto-generated and most HealthLog migrations are forward-only
(column drops, enum additions, index rebuilds). If a release has to
roll back:
1. Roll back the **app container** to the previous image tag.
2. Leave the **database schema** at the newer migration. The previous
app version reads forward-compatible-ish shapes for nearly every
release pair we've shipped on the v1.4.x line; tested in the
release-marathon QA pass.
3. If a column-drop migration ships in the rollback target, hand-roll
the column back via a fresh forward migration (`pnpm prisma migrate
dev --create-only --name vX_Y_Z_rollback_<col>`), edit the SQL,
commit, and re-deploy. Never `migrate resolve --rolled-back` against
production unless you've taken a fresh `pg_dump` first.
+22
View File
@@ -131,6 +131,28 @@ operator attention:
`DEPLOY_WEBHOOK_SECRET`. The app gates the endpoint with a
timing-safe compare against that env var.
### DO NOT set as runtime env vars
The following variables are **build-time only** — they are injected
via `--build-arg` by the `docker-publish` workflow and baked into the
shipped image. Setting them under Coolify's *Environment Variables*
panel has zero effect on the running container, and the stale value
becomes a misleading artefact next time someone audits the deploy
config:
- `NEXT_PUBLIC_APP_VERSION` — sourced from the GHCR tag at build
time. Next.js inlines the value into both the client bundle (so
the `<VersionPoller>` self-healing reload compares against the
shipped shell) and the `/api/version` server route (so the public
endpoint returns the same string). The `env: {…}` block in
`next.config.ts` short-circuits the runtime `process.env` read.
- `NEXT_PUBLIC_APP_BUILD_SHA`, `NEXT_PUBLIC_APP_BUILT_AT` — same
pattern. The CI workflow stamps the short SHA and build timestamp
into the image during `pnpm build`.
If you spot any of these in the Coolify panel, delete the entry. The
image already carries the correct value.
Coolify-Tunnel adds one hop to the chain; pair with
`TRUST_PROXY_HOPS=2`. If you front Coolify itself with Cloudflare,
bump to `3`.
+1 -1
View File
@@ -276,7 +276,7 @@
"layoutTileColumn": "Kachel",
"layoutChartColumn": "Chart",
"dragHandle": "Zum Verschieben ziehen",
"dragHandleHint": "Ziehe den Griff, um dieses Widget neu zu sortieren. Du kannst auch die Pfeiltasten Nach oben und Nach unten verwenden, oder den Griff fokussieren und Leertaste plus Pfeiltasten drücken.",
"dragHandleHint": "Pfeiltasten Hoch/Runter, oder Leertaste plus Pfeiltasten am fokussierten Griff.",
"emptyTitle": "Dein Dashboard ist leer",
"emptyDescription": "Erfasse den ersten Messwert oder Stimmungseintrag. Kacheln und Charts erscheinen automatisch, sobald Daten vorhanden sind.",
"emptyAddMeasurement": "Ersten Messwert erfassen",
+4 -4
View File
@@ -2003,10 +2003,10 @@
"repository": "Source code",
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
"linksHeading": "Fuentes y documentación",
"newerAvailable": "Nueva versión: {tag}",
"tourReplay": "Volver a ver el recorrido",
"tourReplayHint": "Mira el recorrido del panel — útil si lo saltaste en la primera visita."
},
"testConnection": {
"test": "Test connection",
+4 -4
View File
@@ -2003,10 +2003,10 @@
"repository": "Source code",
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
"linksHeading": "Sources et documentation",
"newerAvailable": "Nouvelle version : {tag}",
"tourReplay": "Revoir la visite",
"tourReplayHint": "Voir la visite guidée du tableau de bord — pratique si tu l'as sautée à la première visite."
},
"testConnection": {
"test": "Test connection",
+4 -4
View File
@@ -2003,10 +2003,10 @@
"repository": "Source code",
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
"linksHeading": "Fonti e documentazione",
"newerAvailable": "Nuova versione: {tag}",
"tourReplay": "Rivedi il tour",
"tourReplayHint": "Guarda il tour della dashboard — utile se l'hai saltato alla prima visita."
},
"testConnection": {
"test": "Test connection",
+5 -5
View File
@@ -276,7 +276,7 @@
"layoutTileColumn": "Kafelek",
"layoutChartColumn": "Wykres",
"dragHandle": "Przeciągnij, aby zmienić kolejność",
"dragHandleHint": "Przeciągnij uchwyt, aby zmienić kolejność tego widżetu. Możesz też użyć przycisków strzałek W górę i W dół albo skupić uchwyt i wcisnąć Spację oraz strzałki.",
"dragHandleHint": "Strzałki Góra/Dół, lub Spacja plus strzałki na zaznaczonym uchwycie.",
"emptyTitle": "Twój panel jest pusty",
"emptyDescription": "Zarejestruj pierwszy pomiar lub nastrój. Kafelki i wykresy pojawią się automatycznie, gdy będą dane.",
"emptyAddMeasurement": "Zarejestruj pierwszy pomiar",
@@ -2003,10 +2003,10 @@
"repository": "Source code",
"changelog": "Changelog",
"docs": "Documentation",
"linksHeading": "Sources & docs",
"newerAvailable": "Newer version: {tag}",
"tourReplay": "Replay the tour",
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
"linksHeading": "Źródła i dokumentacja",
"newerAvailable": "Nowa wersja: {tag}",
"tourReplay": "Obejrzyj samouczek",
"tourReplayHint": "Zobacz przewodnik po panelu — przydatne, jeśli pominąłeś go przy pierwszej wizycie."
},
"testConnection": {
"test": "Test connection",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.47.6",
"version": "1.4.49",
"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",
@@ -0,0 +1,41 @@
-- v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
--
-- iOS v0.6.0.7 wires local SpeziScheduler banners for medication doses.
-- Once the client has confirmed (a) APNs permission = `.authorized`,
-- (b) Spezi reminders are actively scheduled, and (c) the binary is
-- >= v0.6.0.7, it flips `notification_prefs.medication.clientManaged`
-- to true via `PATCH /api/auth/me/notification-prefs`. The server-side
-- MEDICATION_REMINDER cron then suppresses the redundant APNs push for
-- that user so the dose only buzzes once.
--
-- Shape (additive — future categories slot in next to `medication`):
-- {
-- "medication": { "clientManaged": boolean }
-- }
--
-- Default `NULL` (the column itself), which the application layer
-- resolves to `{ medication: { clientManaged: false } }` so every
-- existing user keeps the legacy server-side push until the iOS app
-- explicitly opts them in. This preserves UX for clients that don't
-- have working local reminders yet (e.g. the
-- `healthlog-iOS#10` notification-permission-stuck-`notDetermined`
-- scenario).
--
-- Idempotent guard (`IF NOT EXISTS`) matches the 0067 / 0070 / 0071 /
-- 0075 / 0078 pattern so reruns are safe on prod where the migration
-- has already been applied.
--
-- Reversibility: down migration is
-- ALTER TABLE "users" DROP COLUMN IF EXISTS "notification_prefs";
-- A roll-back loses opt-in state for every user that had flipped
-- `clientManaged` (they fall back to server-side reminders) but the
-- application reads tolerate a missing column / null row and default
-- to `clientManaged: false`, so a partial deploy where the schema is
-- rolled back ahead of the app code keeps working.
-- No additional index — reads at cron time are per-user
-- (`WHERE id = $1`), matching the convention set by
-- `source_priority_json` and `doctor_report_prefs_json` on the same
-- table (no GIN index on either).
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "notification_prefs" JSONB;
@@ -0,0 +1,76 @@
-- v1.4.49 — per-attempt push-delivery ledger.
--
-- The /api/admin/notifications/diagnostic endpoint shipped in
-- v1.4.48 carries a `recentPushAttempts` shape but returned an empty
-- array because no persistent table backed the field. This migration
-- adds the table; the matching app-side changes wire fire-and-forget
-- writes from every sender (APNS, web-push, Telegram, NTFY) and swap
-- the diagnostic stub for a real query.
--
-- Schema design:
-- * `id` — cuid surrogate key. There is no natural deduplication
-- key — the same (user, channel, event-type) tuple can fire
-- multiple times legitimately (e.g. two MEDICATION_REMINDERs in
-- a day on different schedules).
-- * `channel` — dispatcher `ChannelType` string ("APNS" |
-- "WEB_PUSH" | "TELEGRAM" | "NTFY").
-- * `event_type` — dispatcher payload `eventType` string. Stored
-- as plain text so future event-type additions
-- never require a follow-up migration.
-- * `result` — "ok" | "error" | "skipped". `skipped` covers
-- the soft "no recipient" / "config missing"
-- cases the sender contracts surface today.
-- * `reason` — sender-side reason on error/skip results,
-- null on `ok`. For APNS this is Apple's reason
-- code (`BadDeviceToken`, `Unregistered`, …).
-- * `created_at` — DEFAULT now(); the diagnostic endpoint orders
-- by this column DESC + maps to the response
-- shape's `at` ISO string.
--
-- Index strategy: `(user_id, created_at DESC)` matches the only
-- documented read path — the diagnostic endpoint pulls the trailing
-- 20 rows per user ordered by recency. The PRIMARY KEY on `id` is
-- never queried by the application; it's there to give the row a
-- stable identity for the retention sweep's WHERE clause.
--
-- Retention: a daily pg-boss schedule (`push-attempt-cleanup` queue,
-- cron `35 3 * * *` Europe/Berlin) deletes rows older than 90 days.
-- The cutoff lines up with the mood-reminder dispatch ledger window
-- (v1.4.38.2) — both surfaces are behavioural footprints we keep
-- long enough to debug a duplicate-push report (~one billing cycle)
-- but no longer. See `handlePushAttemptCleanup` in
-- `src/lib/jobs/reminder-worker.ts`.
--
-- Idempotent guards (`IF NOT EXISTS` on the table + index, plus the
-- `DO $$ … duplicate_object` block on the FK) match the pattern used
-- by 0070 / 0071 / 0072 / 0074 / 0078 so reruns are safe in
-- environments where the migration has already been applied.
--
-- Reversibility: down migration is `DROP TABLE IF EXISTS
-- "push_attempts" CASCADE;`. The data is purely diagnostic — no
-- reader outside the admin endpoint queries it — so a roll-back
-- only loses observability, never user-visible state.
CREATE TABLE IF NOT EXISTS "push_attempts" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"channel" TEXT NOT NULL,
"event_type" TEXT NOT NULL,
"result" TEXT NOT NULL,
"reason" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "push_attempts_pkey" PRIMARY KEY ("id")
);
CREATE INDEX IF NOT EXISTS "push_attempts_user_id_created_at_desc_idx"
ON "push_attempts" ("user_id", "created_at" DESC);
DO $$ BEGIN
ALTER TABLE "push_attempts"
ADD CONSTRAINT "push_attempts_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id")
ON DELETE CASCADE ON UPDATE CASCADE;
EXCEPTION
WHEN duplicate_object THEN NULL;
END $$;
+80 -1
View File
@@ -223,6 +223,30 @@ model User {
// payload when the toggle is off so the data never leaves the DB row.
doctorReportPrefsJson Json? @map("doctor_report_prefs_json")
// v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
//
// Opt-in suppression for redundant server-side pushes. Shape:
// { "medication": { "clientManaged": boolean } }
//
// When `medication.clientManaged === true`, the reminder cron skips
// the APNs `MEDICATION_REMINDER` for this user — iOS v0.6.0.7+
// schedules local SpeziScheduler banners that cover the same dose.
// The iOS app flips the flag to true on first launch after it has
// confirmed (a) APNs permission = `.authorized`, (b) local Spezi
// reminders are actively scheduled, and (c) the binary is
// >= v0.6.0.7. Default `null` (resolved as `clientManaged: false`)
// so existing installs see zero regression — server reminders flow
// unchanged until the iOS client explicitly opts the user in.
//
// Only `MEDICATION_REMINDER` is gated by this flag; MOOD_REMINDER,
// PERSONAL_RECORD, SYSTEM_ALERT, and anomaly pushes flow unchanged.
//
// Persisted via `PATCH /api/auth/me/notification-prefs` with
// deep-merge semantics so future categories (e.g. mood,
// personal_records) slot in next to `medication` without
// overwriting siblings.
notificationPrefs Json? @map("notification_prefs")
// v1.4.47 W3 — per-user Coach opt-out (privacy-leaning users).
//
// When `true`, every Coach mount point on the web client renders
@@ -326,6 +350,13 @@ model User {
// burden. Append-only — a user revoking + re-granting consent mints
// a fresh row so the timeline reads chronologically.
consentReceipts ConsentReceipt[]
// v1.4.49 — per-attempt push-delivery ledger. Every sender (APNS,
// WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget row per
// dispatch so the admin diagnostic endpoint can return the trailing
// 20 attempts ordered by recency. Daily prune at 03:35 Europe/Berlin
// retires rows older than 90 days (see `handlePushAttemptCleanup` in
// `reminder-worker.ts`).
pushAttempts PushAttempt[]
@@map("users")
}
@@ -1409,7 +1440,7 @@ model IntegrationStatus {
lastAttemptAt DateTime? @map("last_attempt_at")
lastError String? @map("last_error") // AES-256-GCM encrypted (may contain token-fragment metadata)
// v1.4.47 W1 — legacy single-column `consecutiveFailures` integer was
// dropped (migration 0076). The per-kind bucket below is now the
// dropped (migration 0077). The per-kind bucket below is now the
// sole source of truth for the alert ladder.
consecutiveFailuresByKind Json? @map("consecutive_failures_by_kind") // v1.4.43 W14 — { transient, reauth_required, persistent }
persistentFailureStartedAt DateTime? @map("persistent_failure_started_at") // v1.4.43 W14 — start of the current persistent streak (drives >24h park)
@@ -2023,3 +2054,51 @@ model ConsentReceipt {
@@index([userId, createdAt(sort: Desc)])
@@map("consent_receipts")
}
// ─── Push attempt ledger (v1.4.49) ───────────────────────────
//
// Per-attempt push-delivery history. Every sender on the dispatcher
// cascade (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one fire-and-forget
// row per dispatch so the admin diagnostic endpoint can surface the
// trailing 20 attempts per user without needing DB shell access.
//
// The write is best-effort: a DB hiccup on the insert must not break
// the actual push delivery, so every sender wraps the `create()` in a
// `void … .catch(() => {})` block. There is no retry — a single
// missing row is acceptable.
//
// Retention: a daily cron at 03:35 Europe/Berlin (between the
// audit-log cleanup at 03:15 and the mood-reminder cleanup at 03:25)
// deletes rows older than 90 days. See `handlePushAttemptCleanup` in
// `src/lib/jobs/reminder-worker.ts`.
//
// `eventType` mirrors the dispatcher's `NotificationPayload.eventType`
// string (`MEDICATION_REMINDER`, `MOOD_REMINDER`, `PERSONAL_RECORD`,
// `MEASUREMENT_ANOMALY`, `SYSTEM_ALERT`, …); stored as plain text so
// future event-type additions never require a schema migration.
//
// `channel` is the dispatcher `ChannelType` string (`APNS`,
// `WEB_PUSH`, `TELEGRAM`, `NTFY`).
//
// `result` is one of `"ok"` | `"error"` | `"skipped"` — `skipped`
// covers the "no recipient" / "config missing" soft cases.
//
// `reason` is the failure reason on `error` or `skipped` results, and
// null on `ok`. For APNS this is Apple's reason code
// (`BadDeviceToken`, `Unregistered`, …); for Web Push it carries the
// classified `web_push_*` reason; Telegram / NTFY likewise mirror
// their sender-side `SendOutcome.reason`.
model PushAttempt {
id String @id @default(cuid())
userId String @map("user_id")
channel String // "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY"
eventType String @map("event_type") // dispatcher event-type string
result String // "ok" | "error" | "skipped"
reason String? // sender-side reason on error/skip; null on ok
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId, createdAt(sort: Desc)])
@@map("push_attempts")
}
+4 -1
View File
@@ -16,8 +16,11 @@ const SPEC_PATH = resolve(__dirname, "..", "docs", "api", "openapi.yaml");
function main(): void {
const document = buildOpenApiDocument();
// Must match the options used by `scripts/generate-openapi.ts`.
// `sortMapEntries` stays off — see that file's header comment for the
// yaml@2 alias-ordering pitfall under sorted output.
const generated = stringify(document, {
sortMapEntries: true,
sortMapEntries: false,
lineWidth: 120,
});
const committed = readFileSync(SPEC_PATH, "utf8");
+15 -5
View File
@@ -9,10 +9,20 @@
* rest of the hand-maintained `docs/api/openapi.yaml` — once coverage is
* complete, the gate flips to hard-fail in v1.4.24+.
*
* The output is stable across re-runs: `yaml@2` is configured with
* `sortMapEntries: true` so map keys land in lexicographic order
* regardless of Node version or the order in which schemas were
* imported.
* The output is stable across re-runs because the upstream order is
* itself deterministic: Zod preserves schema property declaration
* order and the path table is a single object literal in
* `src/lib/openapi/routes.ts`. `yaml@2`'s `sortMapEntries` option is
* intentionally NOT set — when enabled, the emitter reorders keys
* alphabetically and can place an `*alias` reference before its
* `&anchor` definition, which triggers `verifyAliasOrder` and throws
* `Unresolved alias` during `stringify`. This started biting once
* `.meta()`-tagged sub-schemas were nested inside `z.array(...)` for
* the v1.4.48 admin diagnostic envelope: the array's element-schema
* anchor lives under `recentPushAttempts` (sorted to the bottom of
* the data object) while the alias from `notificationChannels`
* (sorted above it) refers to the same `$ref`. Leaving the source
* order intact keeps anchors emitted before their aliases.
*/
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
@@ -24,7 +34,7 @@ const SPEC_PATH = resolve(__dirname, "..", "docs", "api", "openapi.yaml");
function main(): void {
const document = buildOpenApiDocument();
const yaml = stringify(document, {
sortMapEntries: true,
sortMapEntries: false,
lineWidth: 120,
});
writeFileSync(SPEC_PATH, yaml, "utf8");
@@ -0,0 +1,398 @@
/**
* v1.4.48 H-APNs-1 — coverage for the admin notification diagnostic
* endpoint. The route surfaces what the dispatcher would observe when
* targeting the calling admin's account so an operator can debug push
* delivery without DB-shell access; these tests pin:
*
* * 401 for an unauthenticated caller (no session cookie),
* * 403 for an authenticated non-admin (USER role),
* * 200 + the documented envelope shape for an admin with an empty
* device + channel set,
* * APNs token masking (prefix 8 + suffix 8 hex only, full token
* never returned),
* * per-channel `configPresent` reflecting the decrypted JSON blob
* for TELEGRAM / NTFY and the row-existence shortcut for APNS /
* WEB_PUSH (the latter two store delivery targets on sibling
* tables, not in the channel config blob).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
device: {
findMany: vi.fn(),
},
notificationChannel: {
findMany: vi.fn(),
},
// v1.4.49 — backing query for `recentPushAttempts`. The route
// pulls the trailing 20 rows ordered by `createdAt` DESC and maps
// the `createdAt` Date to an `at` ISO string for the response.
pushAttempt: {
findMany: vi.fn(),
},
},
}));
// The route imports requireAdmin from `@/lib/api-handler`. We keep
// apiHandler real so HttpError throws produce the proper 401 / 403
// JSON envelope, but stub requireAdmin to drive the three auth cases.
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
requireAdmin: vi.fn(),
};
});
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => null),
eventStorage: {
run: <T>(_evt: unknown, fn: () => Promise<T>) => fn(),
},
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((value: string) => value.replace(/^enc\(|\)$/g, "")),
encrypt: vi.fn((value: string) => `enc(${value})`),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
const ADMIN_USER_ID = "admin-1";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: ADMIN_USER_ID,
username: "admin",
role: "ADMIN",
},
} as never;
interface DiagnosticEnvelope {
data: {
devices: Array<{
id: string;
platform: string;
hasApnsToken: boolean;
apnsTokenPrefix: string | null;
apnsTokenSuffix: string | null;
apnsEnvironment: string | null;
lastSeenAt: string;
}>;
notificationChannels: Array<{
type: string;
enabled: boolean;
configPresent: boolean;
}>;
recentPushAttempts: Array<{
eventType: string;
channel: string;
result: string;
reason: string | null;
at: string;
}>;
};
error: string | null;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
});
describe("GET /api/admin/notifications/diagnostic — auth gates", () => {
it("returns 401 when the caller has no session", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(401, "Not authenticated"),
);
const res = await GET();
expect(res.status).toBe(401);
const body = (await res.json()) as { data: null; error: string };
expect(body.data).toBeNull();
expect(body.error).toBe("Not authenticated");
});
it("returns 403 when the caller is authenticated but not ADMIN", async () => {
vi.mocked(requireAdmin).mockRejectedValue(
new HttpError(403, "Admin access required"),
);
const res = await GET();
expect(res.status).toBe(403);
const body = (await res.json()) as { data: null; error: string };
expect(body.data).toBeNull();
expect(body.error).toBe("Admin access required");
});
});
describe("GET /api/admin/notifications/diagnostic — empty state", () => {
it("returns the documented envelope shape with empty arrays for an admin with no devices or channels", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
const res = await GET();
expect(res.status).toBe(200);
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.error).toBeNull();
expect(body.data).toEqual({
devices: [],
notificationChannels: [],
recentPushAttempts: [],
});
});
});
describe("GET /api/admin/notifications/diagnostic — device masking", () => {
it("returns hasApnsToken=true and 8/8-char prefix+suffix when device has an APNs token", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
const fullToken =
"abcdef1234567890aabbccddeeff0011223344556677889900112233445566778899aabb";
// Trim to a typical 64-char APNs hex token to keep the assertion
// realistic. The masker doesn't care about length beyond `>= 16`.
const token64 = fullToken.slice(0, 64);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
platform: "ios",
apnsToken: token64,
apnsEnvironment: "production",
lastSeen: new Date("2026-05-22T10:00:00.000Z"),
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.devices).toHaveLength(1);
const device = body.data.devices[0];
expect(device.hasApnsToken).toBe(true);
expect(device.apnsTokenPrefix).toBe(token64.slice(0, 8));
expect(device.apnsTokenSuffix).toBe(token64.slice(-8));
expect(device.apnsEnvironment).toBe("production");
expect(device.lastSeenAt).toBe("2026-05-22T10:00:00.000Z");
// Belt-and-braces: the full token must not appear anywhere in the
// serialised response.
const serialised = JSON.stringify(body);
expect(serialised).not.toContain(token64);
});
it("returns hasApnsToken=false and null prefix/suffix when the device row has no APNs token", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-2",
platform: "ios",
apnsToken: null,
apnsEnvironment: null,
lastSeen: new Date("2026-05-22T11:00:00.000Z"),
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
const device = body.data.devices[0];
expect(device.hasApnsToken).toBe(false);
expect(device.apnsTokenPrefix).toBeNull();
expect(device.apnsTokenSuffix).toBeNull();
});
});
describe("GET /api/admin/notifications/diagnostic — channel configPresent", () => {
it("returns configPresent=true for TELEGRAM when the decrypted blob has both botToken and chatId", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "TELEGRAM",
enabled: true,
config: 'enc({"botToken":"bot:12345","chatId":"678"})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.notificationChannels).toEqual([
{ type: "TELEGRAM", enabled: true, configPresent: true },
]);
});
it("returns configPresent=false for TELEGRAM when chatId is empty", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "TELEGRAM",
enabled: false,
config: 'enc({"botToken":"bot:12345","chatId":""})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "TELEGRAM", enabled: false, configPresent: false },
]);
});
it("returns configPresent=true for NTFY when topic is non-empty", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "NTFY",
enabled: true,
config: 'enc({"serverUrl":"https://ntfy.sh","topic":"hl-test"})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "NTFY", enabled: true, configPresent: true },
]);
});
it("returns configPresent=false for NTFY when topic is missing", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{
type: "NTFY",
enabled: true,
config: 'enc({"serverUrl":"https://ntfy.sh"})',
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "NTFY", enabled: true, configPresent: false },
]);
});
it("treats APNS and WEB_PUSH rows as configPresent=true (delivery target lives on sibling tables)", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{ type: "APNS", enabled: true, config: "enc({})" },
{ type: "WEB_PUSH", enabled: false, config: "enc({})" },
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.notificationChannels).toEqual([
{ type: "APNS", enabled: true, configPresent: true },
{ type: "WEB_PUSH", enabled: false, configPresent: true },
]);
});
it("returns configPresent=false when the encrypted blob is malformed (decryption throws)", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
// Our crypto mock unwraps `enc(...)`; pass a value that yields
// non-JSON after the unwrap so JSON.parse throws and the helper's
// try/catch returns false.
vi.mocked(prisma.notificationChannel.findMany).mockResolvedValue([
{ type: "TELEGRAM", enabled: true, config: "not-encrypted-junk" },
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.notificationChannels).toEqual([
{ type: "TELEGRAM", enabled: true, configPresent: false },
]);
});
});
describe("GET /api/admin/notifications/diagnostic — recentPushAttempts", () => {
it("returns recentPushAttempts as an empty array when the user has no rows", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(body.data.recentPushAttempts).toEqual([]);
});
it("maps push_attempts rows to the documented response shape (createdAt → at ISO string)", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
// The route's findMany select returns `createdAt` as a Date; the
// mapping step normalises to an ISO string under `at`.
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([
{
eventType: "MEDICATION_REMINDER",
channel: "APNS",
result: "ok",
reason: null,
createdAt: new Date("2026-05-22T09:00:00.000Z"),
},
{
eventType: "MOOD_REMINDER",
channel: "TELEGRAM",
result: "error",
reason: "telegram_blocked_by_user",
createdAt: new Date("2026-05-22T08:30:00.000Z"),
},
] as never);
const res = await GET();
const body = (await res.json()) as DiagnosticEnvelope;
expect(res.status).toBe(200);
expect(body.data.recentPushAttempts).toEqual([
{
eventType: "MEDICATION_REMINDER",
channel: "APNS",
result: "ok",
reason: null,
at: "2026-05-22T09:00:00.000Z",
},
{
eventType: "MOOD_REMINDER",
channel: "TELEGRAM",
result: "error",
reason: "telegram_blocked_by_user",
at: "2026-05-22T08:30:00.000Z",
},
]);
});
it("scopes the push-attempt query to the calling admin's userId, ordered desc by recency, capped at 20", async () => {
vi.mocked(requireAdmin).mockResolvedValue(ADMIN_CTX);
vi.mocked(prisma.pushAttempt.findMany).mockResolvedValue([] as never);
await GET();
expect(vi.mocked(prisma.pushAttempt.findMany)).toHaveBeenCalledTimes(1);
const args = vi.mocked(prisma.pushAttempt.findMany).mock.calls[0]?.[0] as {
where: { userId: string };
orderBy: { createdAt: "desc" };
take: number;
};
expect(args.where.userId).toBe(ADMIN_USER_ID);
expect(args.orderBy).toEqual({ createdAt: "desc" });
expect(args.take).toBe(20);
});
});
@@ -0,0 +1,219 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { decrypt } from "@/lib/crypto";
import type { ChannelType } from "@/lib/notifications/types";
export const dynamic = "force-dynamic";
/**
* v1.4.48 H-APNs-1 — admin-only diagnostic endpoint for the
* notification subsystem. Surfaces what the dispatcher would see when
* targeting the calling admin's account so an operator can debug an
* iOS / Web Push / Telegram / ntfy issue without needing DB shell
* access. This fills the gap that bottlenecked the v1.4.47 APNs hunt
* — every "is the token there?" / "is the channel enabled?" question
* was a Coolify exec round-trip.
*
* Response shape:
* {
* devices: Device[], — APNs token presence + masked
* prefix/suffix only; never the
* full token.
* notificationChannels: Channel[] — per-channel enabled + whether
* the channel-specific config
* blob is present.
* recentPushAttempts: PushAttempt[] — last 20 push deliveries for
* the calling user, ordered by
* recency. Backed by the
* `push_attempts` table — every
* sender (APNS, WEB_PUSH,
* TELEGRAM, NTFY) writes one
* fire-and-forget row per
* dispatch. v1.4.49 closed the
* empty-array stub by adding
* the table + the write path.
* }
*
* Security: admin-only (cookie auth, never Bearer). The route never
* returns the full APNs token — only an 8-char hex prefix + 8-char
* suffix so the operator can correlate with iOS-side diagnostics.
*/
interface DiagnosticDevice {
id: string;
platform: string;
hasApnsToken: boolean;
apnsTokenPrefix: string | null;
apnsTokenSuffix: string | null;
apnsEnvironment: string | null;
lastSeenAt: string;
}
interface DiagnosticChannel {
type: ChannelType | string;
enabled: boolean;
configPresent: boolean;
}
interface DiagnosticPushAttempt {
eventType: string;
channel: string;
result: string;
reason: string | null;
at: string;
}
interface DiagnosticPayload {
devices: DiagnosticDevice[];
notificationChannels: DiagnosticChannel[];
recentPushAttempts: DiagnosticPushAttempt[];
}
/**
* Map an APNs hex token to its 8-char prefix + 8-char suffix. APNs
* device tokens are 64-char hex strings; we surface 8 + 8 = 16 chars
* total (25 % of the token), which is enough for the operator to
* cross-check against an iOS-side log line without disclosing the
* delivery target. Tokens shorter than 16 chars are treated as opaque
* (prefix / suffix both null) — that shape never appears in
* production, but defensive against partial inserts during testing.
*/
function maskApnsToken(
token: string | null,
): { prefix: string | null; suffix: string | null } {
if (!token || token.length < 16) return { prefix: null, suffix: null };
return {
prefix: token.slice(0, 8),
suffix: token.slice(-8),
};
}
/**
* Decide whether the channel's encrypted-JSON config blob carries the
* key the corresponding sender will actually read. We decrypt + parse
* inside a try/catch so a corrupted ciphertext row (or an upstream
* encryption-key rotation) cannot 500 the diagnostic endpoint — the
* worst case is `configPresent: false` and the operator investigates
* why the row is unreadable.
*
* The required field per channel type:
* - TELEGRAM → `chatId` + `botToken`
* - NTFY → `topic`
* - WEB_PUSH → no per-channel blob (subscriptions live in the
* `push_subscriptions` table); treat the row's mere
* existence as `configPresent: true`.
* - APNS → no per-channel blob (device tokens live on the
* `devices` table); treat the row's mere existence as
* `configPresent: true`.
*/
function isChannelConfigPresent(
type: string,
encryptedConfig: string,
): boolean {
if (type === "WEB_PUSH" || type === "APNS") {
// These channels store their delivery target on sibling tables
// (`push_subscriptions` / `devices`), not in the channel-row
// config blob. The row's existence is the signal.
return true;
}
try {
const parsed = JSON.parse(decrypt(encryptedConfig)) as Record<
string,
unknown
>;
if (type === "TELEGRAM") {
return (
typeof parsed.chatId === "string" &&
parsed.chatId.length > 0 &&
typeof parsed.botToken === "string" &&
parsed.botToken.length > 0
);
}
if (type === "NTFY") {
return typeof parsed.topic === "string" && parsed.topic.length > 0;
}
return false;
} catch {
return false;
}
}
export const GET = apiHandler(async () => {
const { user } = await requireAdmin();
annotate({ action: { name: "admin.notifications.diagnostic" } });
const [devices, channels, pushAttempts] = await Promise.all([
prisma.device.findMany({
where: { userId: user.id },
select: {
id: true,
platform: true,
apnsToken: true,
apnsEnvironment: true,
lastSeen: true,
},
orderBy: { lastSeen: "desc" },
}),
prisma.notificationChannel.findMany({
where: { userId: user.id },
select: { type: true, enabled: true, config: true },
}),
// v1.4.49 — trailing 20 push attempts for the calling admin's own
// account. The `(user_id, created_at DESC)` index on `push_attempts`
// turns the orderBy + take into a single index scan, so the cost is
// bounded regardless of the user's lifetime push volume.
prisma.pushAttempt.findMany({
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 20,
select: {
eventType: true,
channel: true,
result: true,
reason: true,
createdAt: true,
},
}),
]);
const diagnosticDevices: DiagnosticDevice[] = devices.map((d) => {
const { prefix, suffix } = maskApnsToken(d.apnsToken);
return {
id: d.id,
platform: d.platform,
hasApnsToken: d.apnsToken !== null && d.apnsToken !== "",
apnsTokenPrefix: prefix,
apnsTokenSuffix: suffix,
apnsEnvironment: d.apnsEnvironment,
lastSeenAt: d.lastSeen.toISOString(),
};
});
const diagnosticChannels: DiagnosticChannel[] = channels.map((c) => ({
type: c.type,
enabled: c.enabled,
configPresent: isChannelConfigPresent(c.type, c.config),
}));
// v1.4.49 — backed by the `push_attempts` table. Map `createdAt`
// → `at` ISO string so the response shape matches the
// `DiagnosticPushAttempt` interface above (the existing consumer
// contract uses `at`, not `createdAt`).
const recentPushAttempts: DiagnosticPushAttempt[] = pushAttempts.map((a) => ({
eventType: a.eventType,
channel: a.channel,
result: a.result,
reason: a.reason,
at: a.createdAt.toISOString(),
}));
const payload: DiagnosticPayload = {
devices: diagnosticDevices,
notificationChannels: diagnosticChannels,
recentPushAttempts,
};
return apiSuccess(payload);
});
+11 -4
View File
@@ -305,10 +305,12 @@ describe("GET /api/analytics", () => {
// contract the dashboard tile strip reads.
it("returns the slim summaries slice when ?slice=summaries is set", async () => {
const fiveDaysAgo = new Date(Date.now() - 5 * 24 * 60 * 60 * 1000);
// v1.4.36 — slim slice now opens with a cheap `SELECT COUNT(*) FROM
// measurement_rollups` probe. Force `n: 0` so the route takes the
// legacy heavy-aggregate fallback path that this test's fixtures
// expect (aggregate + latest, two `$queryRaw` passes).
// v1.4.36 — slim slice opens with a cheap `SELECT COUNT(*) FROM
// measurement_rollups` probe; `n: 0` forces the cold-fallback
// path. v1.4.48 M0 split that path's single aggregate query into
// two parallel ones (all-time + windowed), so the cold fixture
// now mocks 4 `$queryRaw` calls: coverage + allTime + windowed +
// latest.
vi.mocked(prisma.$queryRaw)
.mockResolvedValueOnce([{ n: BigInt(0) }] as never)
.mockResolvedValueOnce([
@@ -318,6 +320,11 @@ describe("GET /api/analytics", () => {
min_value: 80.0,
max_value: 84.5,
mean_value: 82.1,
},
] as never)
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 82.0,
avg30: 82.2,
slope7: -0.05,
+15 -12
View File
@@ -9,7 +9,7 @@
*
* The toggle is reachable from Settings → Insights as a `<Switch>`
* labelled "Hide Coach" / "Coach ausblenden". Default `false` (Coach
* visible) — see migration `0076_v1447_user_disable_coach` for the
* visible) — see migration `0078_v1447_user_disable_coach` for the
* column-level documentation.
*
* Idempotent. The endpoint always returns the resolved next-state
@@ -19,13 +19,22 @@
* batch settings round-trips after a passkey re-pair and a tight
* 5/min would surface as a 429 on a normal onboarding flow.
*/
import { z } from "zod";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
const patchBodySchema = z.object({ disableCoach: z.boolean() });
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
@@ -72,19 +81,13 @@ export const PATCH = apiHandler(async (req: Request) => {
throw new HttpError(422, "disable-coach.body.invalid_json");
}
// Hand-rolled validation — single boolean field. Keeps the
// prompt-injection surface visibly empty in code review (no string
// ever reaches an LLM).
if (
!body ||
typeof body !== "object" ||
typeof (body as { disableCoach?: unknown }).disableCoach !== "boolean"
) {
const parsed = patchBodySchema.safeParse(body);
if (!parsed.success) {
annotate({ action: { name: "auth.me.disable-coach.patch.invalid_shape" } });
throw new HttpError(422, "disable-coach.body.invalid_shape");
return returnAllZodIssues(parsed.error, 422);
}
const next = (body as { disableCoach: boolean }).disableCoach;
const next = parsed.data.disableCoach;
const previous = await prisma.user.findUnique({
where: { id: user.id },
@@ -0,0 +1,270 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
toJson: <T,>(v: T) => v,
}));
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("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 59,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PATCH } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
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 mkPatch(body: unknown): Request {
return new Request("http://localhost/api/auth/me/notification-prefs", {
method: "PATCH",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns the documented defaults for a fresh user (null row)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: false } });
});
it("returns the resolved prefs when the row holds a value", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
});
it("returns defaults when the persisted shape has drifted", async () => {
// Forward-compat: an admin hand-edit / future-rename should not
// crash the GET.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { unknownCategory: { foo: "bar" } },
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/notification-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: false } });
});
});
describe("PATCH /api/auth/me/notification-prefs", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("flips medication.clientManaged on and writes the audit row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
},
});
expect(auditLog).toHaveBeenCalledWith(
"user.notification-prefs.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: { medication: { clientManaged: false } },
next: { medication: { clientManaged: true } },
changed: ["medication"],
}),
}),
);
});
it("rejects malformed JSON with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const req = new Request(
"http://localhost/api/auth/me/notification-prefs",
{
method: "PATCH",
body: "{ not valid json",
headers: { "Content-Type": "application/json" },
},
);
const res = await (PATCH as (r: Request) => Promise<Response>)(req);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects a non-boolean clientManaged with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: "yes" } }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("deep-merges over existing siblings without overwriting them", async () => {
// Forward-compat: a row that already carries an unknown sibling
// category (e.g. a future "mood" key persisted by a newer client)
// must survive a PATCH that only touches `medication`. The route
// parses with the current zod schema first, then deep-merges the
// input over the parsed (defaulted) base. Today, "unknown sibling"
// resolves back to defaults; the test pins that the medication
// PATCH still lands and that future schema growth (adding new
// siblings to the zod shape) will preserve them.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: false } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
},
});
});
it("returns the merged shape unchanged on an empty PATCH body", async () => {
// Idempotent — an empty body keeps the row as-is and still writes
// the audit trail (mirrors the disable-coach posture).
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
notificationPrefs: { medication: { clientManaged: true } },
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({}),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { medication: { clientManaged: boolean } };
};
expect(env.data).toEqual({ medication: { clientManaged: true } });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
notificationPrefs: { medication: { clientManaged: true } },
},
});
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (PATCH as (r: Request) => Promise<Response>)(
mkPatch({ medication: { clientManaged: true } }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,133 @@
/**
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences endpoint.
*
* GET /api/auth/me/notification-prefs — current resolved prefs.
* PATCH /api/auth/me/notification-prefs — body `{ medication?: { clientManaged?: boolean } }`.
* Deep-merges the supplied
* shape over the persisted
* row so future categories
* slot in next to
* `medication` without
* overwriting siblings.
*
* Auth is shared via `requireAuth()` so cookie sessions + Bearer
* tokens both work (the iOS app uses Bearer). Default shape lives in
* `parseNotificationPrefs` — a null DB row resolves to
* `{ medication: { clientManaged: false } }`, preserving the legacy
* server-side reminder behaviour for any user the iOS client has not
* yet opted in.
*
* Idempotent. The endpoint always returns the fully-resolved next
* state so the client can hard-set the optimistic update without an
* extra round-trip. Rate-limit is intentionally generous (60/min) —
* matches the disable-coach route which is the closest analogue.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
returnAllZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma, toJson } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
notificationPrefsSchema,
parseNotificationPrefs,
resolveNotificationPrefs,
} from "@/lib/validations/notification-prefs";
export const dynamic = "force-dynamic";
const PATCH_RATE_LIMIT = 60;
const PATCH_WINDOW_MS = 60_000;
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.notification-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
});
return apiSuccess(parseNotificationPrefs(row?.notificationPrefs ?? null));
});
export const PATCH = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(
`notification-prefs:patch:${user.id}`,
PATCH_RATE_LIMIT,
PATCH_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "notification-prefs.body.invalid_json");
}
const parsed = notificationPrefsSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.notification-prefs.patch.invalid_shape" },
meta: { issues: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422);
}
// Deep-merge the partial input over the current row so a PATCH that
// only touches `medication` does not overwrite future sibling
// categories (mood, personal_records, ...). The full resolved shape
// is persisted so the column layout stays stable across schema
// additions.
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { notificationPrefs: true },
});
const previous = parseNotificationPrefs(current?.notificationPrefs ?? null);
const merged = resolveNotificationPrefs(
current?.notificationPrefs ?? null,
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
data: { notificationPrefs: toJson(merged) },
});
// Capture which categories the PATCH actually touched so the audit
// trail records the user's intent, not just the full resolved row.
const changedCategories = Object.keys(parsed.data);
await auditLog("user.notification-prefs.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous,
next: merged,
changed: changedCategories,
},
});
annotate({
action: { name: "user.notification_prefs.update" },
meta: {
changed: changedCategories,
medication_client_managed: merged.medication.clientManaged,
},
});
return apiSuccess(merged);
});
+1 -1
View File
@@ -34,7 +34,7 @@ export const GET = apiHandler(async () => {
lastReportPracticeName: user.lastReportPracticeName ?? null,
// v1.4.47 W3 — per-user Coach opt-out. Default `false` if the
// column is absent (partial-deploy rollback safety, see migration
// 0076 commentary). Every Coach mount point on the client checks
// 0078 commentary). Every Coach mount point on the client checks
// `user.disableCoach` BELOW the operator-level `flags.coach`
// short-circuit; both gates must agree to paint the affordance.
disableCoach: user.disableCoach ?? false,
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -94,6 +94,14 @@ beforeEach(() => {
} as never);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
// assertion inside an `it` that opted into fake timers would otherwise
// leak the fake clock into the next test in the file (or, worse, the
// next file in the same vitest worker).
afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/dashboard/summary", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -653,7 +661,7 @@ describe("GET /api/dashboard/summary", () => {
data: { compliance: { scheduledToday: number; takenToday: number } };
};
expect(body.data.compliance.scheduledToday).toBe(1);
vi.useRealTimers();
// v1.4.48 L13 — suite-level `afterEach` restores real timers.
});
it("computes intake compliance for today", async () => {
@@ -36,6 +36,15 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
};
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -48,6 +57,7 @@ vi.mock("next/headers", () => ({
import { PUT, __resetAuditDedupMemoForTests } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
import { __resetAllCachesForTests } from "@/lib/cache/server-cache";
const SESSION_OK = {
@@ -135,13 +145,14 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(call.data.userId).toBe("user-1");
expect(call.data.action).toBe("dashboard.widgets.validation-failed");
const details = JSON.parse(call.data.details) as {
issues: Array<{ path: string; code: string; message: string }>;
issues: Array<{ path: string; code: string }>;
};
expect(details.issues.length).toBe(2);
for (const issue of details.issues) {
// Audit row carries the same sanitised shape — issue.params never
// hits the ledger either.
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
// v1.4.49 — audit row is the persisted surface. `message`
// strips here so a future Zod code that embeds the offending
// value cannot leak into the ledger.
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
@@ -165,6 +176,58 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
expect(body2.details.issues.length).toBe(2);
});
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-1)", async () => {
const payload = { version: 2, widgets: [], extraGarbage: "from-ios" };
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
// Top-level keys mirror the iOS-sent payload (NOT the schema keys).
expect(meta.received_keys).toEqual(
expect.arrayContaining(["version", "widgets", "extraGarbage"]),
);
// Excerpt is a JSON-stringified prefix, hard-capped at 256 chars.
expect(typeof meta.received_shape_excerpt).toBe("string");
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
expect(meta.received_shape_excerpt as string).toContain("\"version\":2");
// zod_issues is the same sanitised array surfaced under details.issues.
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
expect(Array.isArray(issues)).toBe(true);
expect(issues.length).toBeGreaterThanOrEqual(2);
for (const issue of issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("caps received_shape_excerpt at 256 chars even for a large iOS payload", async () => {
const widgets = Array.from({ length: 30 }, (_, i) => ({
id: `widget-${i}-${"x".repeat(20)}`,
visible: true,
order: i,
}));
const res = await callPut(makeReq({ version: 99, widgets }));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
it("does not block the 422 response when the audit-row write rejects", async () => {
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
new Error("db down"),
@@ -178,4 +241,65 @@ describe("PUT /api/dashboard/widgets — 422 multi-issue envelope (v1.4.42 W2)",
};
expect(body.details.issues.length).toBe(2);
});
it("redacts sensitive keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
// Caller adds a credential-shaped field — must not land in the
// wide-event excerpt verbatim. The denylist also covers nested
// members so `payload.apiKey` is redacted, while `version` /
// `widgets` stay readable for operator debug.
const payload = {
version: 2,
widgets: [],
apnsToken: "ff".repeat(32),
authorization: "Bearer leaked",
payload: { apiKey: "sk_live_xxx" },
};
const res = await callPut(makeReq(payload));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"dashboard.widgets.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
// Sensitive values are never written into the excerpt.
expect(excerpt).not.toContain("ff".repeat(32));
expect(excerpt).not.toContain("Bearer leaked");
expect(excerpt).not.toContain("sk_live_xxx");
// The redactor leaves the literal sentinel behind so an operator
// can still see the shape of the rejected payload.
expect(excerpt).toContain("[redacted]");
// Non-sensitive keys still surface for debugging.
expect(meta.received_keys).toEqual(
expect.arrayContaining([
"version",
"widgets",
"apnsToken",
"authorization",
"payload",
]),
);
});
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
// The wide-event meta keeps full message for operator debugging,
// but the persisted auditLog row must only carry `path` + `code`
// so a future Zod code that embeds the offending value cannot
// leak into the ledger.
await callPut(makeReq({ version: 2, widgets: [] }));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
});
+28 -2
View File
@@ -8,6 +8,7 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
safeJson,
returnAllZodIssues,
sanitiseZodIssues,
@@ -28,6 +29,7 @@ import { Prisma } from "@/generated/prisma/client";
import { z } from "zod/v4";
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
import { cached, caches, type ServerCache } from "@/lib/cache/server-cache";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { NextRequest } from "next/server";
// Single source of truth — every widget id rendered by the Settings →
@@ -145,9 +147,25 @@ export const PUT = apiHandler(async (request: NextRequest) => {
// `/api/admin/audit` for these without combing the iOS dev
// console.
const issues = sanitiseZodIssues(parsed.error.issues);
// v1.4.48 H-iOS-1 — surface the iOS-sent payload shape alongside
// the Zod rejection so a single wide-event line carries enough
// detail to diagnose serialiser drift in `HealthLog-iOS`. We log
// ONLY the top-level keys plus a hard 256-char JSON excerpt — never
// the full body — so PII / token-like fields cannot leak. The
// diagnostic shape is built by the shared `buildPayloadDiagnostic`
// helper (v1.4.49) so the widget + series routes can't drift; the
// body is routed through `redactSensitiveFields` first so any
// future field matching the denylist (password / token / secret /
// apiKey / authorization / csrfState / nonce) lands as the literal
// `"[redacted]"` instead of its raw value.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(body));
annotate({
action: { name: "dashboard.widgets.validation-failed" },
meta: { issue_count: issues.length },
meta: {
issue_count: issues.length,
...payloadDiagnostic,
zod_issues: issues,
},
});
// v1.4.43 B2 — gate the breadcrumb behind a 60 s (userId, action) dedup
// so a misbehaving iOS client retrying every second cannot pump
@@ -160,12 +178,20 @@ export const PUT = apiHandler(async (request: NextRequest) => {
)
) {
// Best-effort breadcrumb — never block the 422 on a write miss.
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value in their default message
// (`invalid_enum_value` and similar) cannot leak user content
// through the audit surface. The wide-event excerpt above
// already carries the shape signal for operator debugging.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "dashboard.widgets.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -85,12 +85,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "devices.register.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row. Device
// registration carries `token` + `apnsToken`; the default Zod
// message for `invalid_format` echoes the offending value, which
// would persist an APNs token (or a forged near-miss) into the
// audit ledger.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "devices.register.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+7 -1
View File
@@ -103,12 +103,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "ingest.medication.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; external
// ingest carries free-text `medicationName` + opaque
// `idempotencyKey`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: apiToken.userId,
action: "ingest.medication.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+7 -1
View File
@@ -152,12 +152,18 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
action: { name: "integrations.healthkit.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// HealthKit patch payload carries caller-provided `id` + `kind`
// strings that Zod can echo.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "integrations.healthkit.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -70,12 +70,20 @@ export const PUT = apiHandler(
action: { name: "measurements.update.validation-failed" },
meta: { issue_count: issues.length, measurement_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// update schema carries free-text `notes`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.update.validation-failed",
details: JSON.stringify({ issues, measurementId: id }),
details: JSON.stringify({
issues: auditIssues,
measurementId: id,
}),
},
})
.catch(() => {
+19 -3
View File
@@ -56,12 +56,18 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "measurements.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row to keep
// any free-text query slice (custom `dayKey`, attacker-shaped
// `type=`) out of the persisted breadcrumb.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -583,12 +589,17 @@ async function postMeasurement(request: NextRequest) {
action: { name: "measurements.create.batch.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; batch
// rows carry free-text `notes` per measurement.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.batch.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -671,12 +682,17 @@ async function postMeasurement(request: NextRequest) {
action: { name: "measurements.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; single
// measurement carries free-text `notes`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -16,6 +16,15 @@ vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/context", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/logging/context")>();
return {
...actual,
annotate: vi.fn(),
};
});
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
@@ -28,6 +37,7 @@ vi.mock("next/headers", () => ({
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { annotate } from "@/lib/logging/context";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -157,6 +167,50 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
expect(call.data.action).toBe("measurements.series.validation-failed");
});
it("surfaces received_keys + received_shape_excerpt + zod_issues in the wide-event meta (v1.4.48 H-iOS-2)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("kind=garbage&days=0&extraGarbage=fromIos"));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
expect(annotated, "validation-failed annotate call").toBeTruthy();
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect(meta.received_keys).toEqual(
expect.arrayContaining(["kind", "days", "extraGarbage"]),
);
expect(typeof meta.received_shape_excerpt).toBe("string");
expect((meta.received_shape_excerpt as string).length).toBeLessThanOrEqual(
256,
);
expect(meta.received_shape_excerpt as string).toContain("\"kind\":\"garbage\"");
const issues = meta.zod_issues as Array<{ path: string; code: string }>;
expect(Array.isArray(issues)).toBe(true);
expect(issues.length).toBeGreaterThanOrEqual(2);
for (const issue of issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "message", "path"]);
}
});
it("caps received_shape_excerpt at 256 chars even for a long iOS query", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const long = "x".repeat(500);
const res = await GET(req(`kind=garbage&days=0&junk=${long}`));
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
expect((meta.received_shape_excerpt as string).length).toBe(256);
});
it("does not block the 422 when the audit-row write rejects", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.auditLog.create).mockRejectedValueOnce(
@@ -165,4 +219,47 @@ describe("GET /api/measurements/series — 422 multi-issue (v1.4.43 W6)", () =>
const res = await GET(req("kind=garbage&days=0"));
expect(res.status).toBe(422);
});
it("redacts sensitive query keys before writing the wide-event received_shape_excerpt (v1.4.49)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
// An attacker-shaped query that mixes a valid `kind` with a
// credential-shaped `token` param. The excerpt must keep `kind`
// readable for operator debug but redact the token verbatim.
const res = await GET(
req("kind=garbage&days=0&token=hlk_secret_value&apiKey=sk_live_xxx"),
);
expect(res.status).toBe(422);
const annotated = vi.mocked(annotate).mock.calls.find(
(call) =>
(call[0] as { action?: { name?: string } })?.action?.name ===
"measurements.series.validation-failed",
);
const meta = (annotated![0] as { meta?: Record<string, unknown> }).meta!;
const excerpt = meta.received_shape_excerpt as string;
expect(excerpt).not.toContain("hlk_secret_value");
expect(excerpt).not.toContain("sk_live_xxx");
expect(excerpt).toContain("[redacted]");
// The key inventory still surfaces so operators see the shape.
expect(meta.received_keys).toEqual(
expect.arrayContaining(["kind", "days", "token", "apiKey"]),
);
});
it("strips `message` from the audit-ledger issues row (v1.4.49)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
await GET(req("kind=garbage&days=0"));
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.auditLog.create).mock.calls[0]?.[0] as {
data: { details: string };
};
const parsed = JSON.parse(call.data.details) as {
issues: Array<Record<string, unknown>>;
};
for (const issue of parsed.issues) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
}
});
});
+26 -5
View File
@@ -14,11 +14,13 @@ import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiSuccess,
buildPayloadDiagnostic,
returnAllZodIssues,
sanitiseZodIssues,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { redactSensitiveFields } from "@/lib/observability/redact-payload";
import type { MeasurementType } from "@/generated/prisma/client";
const kindEnum = z.enum([
@@ -70,23 +72,42 @@ function stdDev(values: number[]): number {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const parsed = querySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
const rawQuery = Object.fromEntries(request.nextUrl.searchParams);
const parsed = querySchema.safeParse(rawQuery);
if (!parsed.success) {
// v1.4.43 W6 — iOS chart loader hot path; multi-issue 422 +
// audit breadcrumb keyed `measurements.series.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
// v1.4.48 H-iOS-2 — surface the iOS-sent query shape alongside
// the Zod rejection. Top-level keys + a hard 256-char JSON excerpt
// only; never the full payload, so token-shaped query params (we
// do not currently accept any but the truncation keeps that
// invariant cheap) cannot leak. Shared helper via v1.4.49 so the
// widget + series routes can't drift on the diagnostic shape; the
// query map is routed through `redactSensitiveFields` first so any
// future credential-shaped query string (`token`, `apiKey`,
// `csrfState`) redacts to `"[redacted]"` before the truncate.
const payloadDiagnostic = buildPayloadDiagnostic(redactSensitiveFields(rawQuery));
annotate({
action: { name: "measurements.series.validation-failed" },
meta: { issue_count: issues.length },
meta: {
issue_count: issues.length,
...payloadDiagnostic,
zod_issues: issues,
},
});
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value (`invalid_enum_value` etc.)
// cannot leak user content through the audit surface.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "measurements.series.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -62,12 +62,20 @@ export const POST = apiHandler(
action: { name: "medications.intake.import.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; CSV
// imports carry caller-provided strings that Zod may echo.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.import.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
+17 -2
View File
@@ -49,12 +49,20 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
action: { name: "medications.intake.create.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// intake payload carries `idempotencyKey` (opaque caller string).
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.create.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
@@ -211,12 +219,19 @@ export const GET = apiHandler(
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length, medication_id: id },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues, medicationId: id }),
details: JSON.stringify({
issues: auditIssues,
medicationId: id,
}),
},
})
.catch(() => {
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
@@ -103,6 +103,14 @@ beforeEach(() => {
vi.mocked(prisma.auditLog.create).mockResolvedValue({} as never);
});
// v1.4.48 L13 — restore real timers no matter how a test exits. A failed
// assertion inside an `it` that opted into fake timers would otherwise
// leak the fake clock into the next test in the file (or, worse, the
// next file in the same vitest worker).
afterEach(() => {
vi.useRealTimers();
});
describe("GET /api/medications/intake", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
@@ -262,7 +270,7 @@ describe("GET /api/medications/intake", () => {
);
expect(res.status).toBe(200);
expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled();
vi.useRealTimers();
// v1.4.48 L13 — suite-level `afterEach` restores real timers.
});
it("returns compliance buckets for the last N days", async () => {
+5 -1
View File
@@ -120,12 +120,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
action: { name: "medications.intake.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.bulk.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+11 -2
View File
@@ -82,12 +82,17 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "medications.intake.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; iOS-sent
// query strings can flow into Zod issue messages.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -288,12 +293,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "medications.intake.update.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "medications.intake.update.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+6 -1
View File
@@ -75,12 +75,17 @@ export const PUT = apiHandler(
action: { name: "mood-entries.update.validation-failed" },
meta: { issue_count: issues.length, moodEntryId: id },
});
// v1.4.49 — strip `message` from the audit-ledger row; mood
// update carries free-text `note` + `tags`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.update.validation-failed",
details: JSON.stringify({ issues, moodEntryId: id }),
details: JSON.stringify({ issues: auditIssues, moodEntryId: id }),
},
})
.catch(() => {
+6 -1
View File
@@ -122,12 +122,17 @@ async function postBulk(request: NextRequest): Promise<Response> {
action: { name: "mood.bulk.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; bulk mood
// entries carry free-text `note` + `tags` per row.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood.bulk.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+16 -2
View File
@@ -44,12 +44,20 @@ export const GET = apiHandler(async (request: NextRequest) => {
action: { name: "mood-entries.list.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row so Zod
// codes that embed the offending value (`invalid_enum_value` etc.)
// cannot leak user-typed content. Mood-entries carry free-text
// `note` + `tags`; defensive strip everywhere mood content can
// reach a Zod issue.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.list.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -116,12 +124,18 @@ async function postMoodEntry(request: NextRequest) {
action: { name: "mood-entries.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mood-create schema's free-text `note` + `tags` could land in a
// Zod issue message verbatim.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "mood-entries.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
+9 -1
View File
@@ -57,10 +57,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
: defaultLocale;
const t = getServerTranslator(locale).t;
// Send as MEDICATION_REMINDER so the dispatcher's time-sensitive /
// priority-10 branch fires. Otherwise the test runs at the default
// `active` interruption-level and iOS may summarise it into the
// Notification Center rather than presenting a lock-screen banner —
// leaving the user with no signal whether real medication reminders
// (which DO use time-sensitive) will surface on the lock screen.
// The title/body strings still read "Test notification" so the user
// doesn't mistake this for an actual scheduled dose.
const result = await sendViaApns(user.id, {
title: t("notifications.admin.testNotificationTitle"),
message: t("notifications.admin.testNotificationBody"),
eventType: "SYSTEM_ALERT",
eventType: "MEDICATION_REMINDER",
});
annotate({
+6 -1
View File
@@ -65,12 +65,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
action: { name: "tokens.create.validation-failed" },
meta: { issue_count: issues.length },
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mint payload carries a free-text `name`.
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
prisma.auditLog
.create({
data: {
userId: user.id,
action: "tokens.create.validation-failed",
details: JSON.stringify({ issues }),
details: JSON.stringify({ issues: auditIssues }),
},
})
.catch(() => {
@@ -0,0 +1,308 @@
/**
* v1.4.48 M3 — unit-level coverage for the atomic nonce-consumption
* branch.
*
* The integration tests in `tests/integration/withings-oauth*.test.ts`
* already pin the full handshake against real Postgres (happy path,
* replay, expired, cross-user). This file specifically targets the
* delete-first race window the M3 refactor closed: two concurrent
* callbacks with the same nonce — the first wins, the second hits
* Prisma P2025 and lands on the replay branch.
*
* Co-tests M7 — non-P2025 delete failures (DB-connection drop /
* integrity violation) surface via Wide-Event `addWarning` instead
* of being silently swallowed.
*
* v1.4.49 — assertions updated for the four-way reason-tag split
* (`csrf1`, `replay`, `expired`, `cross_user`) and the QA-2
* err.name-only warning template.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "user-1" },
session: { id: "sess-1" },
})),
HttpError: class HttpError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
},
}));
vi.mock("@/lib/db", () => ({
prisma: {
withingsOAuthState: {
delete: vi.fn(),
},
withingsConnection: {
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((s: string) => `enc(${s})`),
}));
vi.mock("@/lib/withings/client", () => ({
exchangeCode: vi.fn(),
WITHINGS_OAUTH_SCOPE: "user.metrics,user.activity",
}));
vi.mock("@/lib/withings/credentials", () => ({
getUserWithingsCredentials: vi.fn(),
}));
vi.mock("@/lib/withings/sync", () => ({
setupWebhook: vi.fn(() => Promise.resolve()),
}));
vi.mock("@/lib/integrations/status", () => ({
markReconnected: vi.fn(),
}));
const addWarning = vi.fn();
const annotateMock = vi.hoisted(() => vi.fn());
vi.mock("@/lib/logging/context", () => ({
annotate: annotateMock,
getEvent: vi.fn(() => ({ addWarning, setError: vi.fn() })),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
const NONCE = "abcdefghijklmnopqrstuv"; // 22-char base64url
const APP_URL = "http://localhost:3000";
function callbackRequest(state: string, cookieState: string): NextRequest {
return new NextRequest(
`http://localhost/api/withings/callback?code=auth-code-1&state=${encodeURIComponent(state)}`,
{
method: "GET",
headers: {
cookie: `withings_state=${cookieState}`,
},
},
);
}
beforeEach(() => {
vi.clearAllMocks();
process.env.NEXT_PUBLIC_APP_URL = APP_URL;
});
describe("withings/callback atomic nonce consumption (M3)", () => {
it("concurrent callbacks with the same nonce — only one succeeds, the second hits the P2025 replay branch", async () => {
// First callback: delete returns the row (win the race).
// Second callback: delete throws P2025 (row already consumed).
const winningRow = {
nonce: NONCE,
userId: "user-1",
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
createdAt: new Date(),
};
const p2025 = new Prisma.PrismaClientKnownRequestError(
"An operation failed because it depends on one or more records that were required but not found.",
{ code: "P2025", clientVersion: "x" },
);
const deleteMock = vi.mocked(prisma.withingsOAuthState.delete);
deleteMock
.mockResolvedValueOnce(winningRow as never)
.mockRejectedValueOnce(p2025);
// Stub the rest of the happy path for the winning request — code
// exchange returns plausible tokens so the upsert + redirect run.
const { getUserWithingsCredentials } = await import(
"@/lib/withings/credentials"
);
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
clientId: "cid",
clientSecret: "csec",
} as never);
const { exchangeCode } = await import("@/lib/withings/client");
vi.mocked(exchangeCode).mockResolvedValue({
userid: "wuid",
access_token: "atok",
refresh_token: "rtok",
expires_in: 3600,
} as never);
// Fire both legs concurrently. The mock's call ordering is FIFO,
// so whichever `await` hits the Prisma mock first gets the
// success result. Both legs use the same nonce + cookie value.
const reqA = callbackRequest(NONCE, NONCE);
const reqB = callbackRequest(NONCE, NONCE);
const [resA, resB] = await Promise.all([GET(reqA), GET(reqB)]);
// Both responses are 307 redirects.
expect(resA.status).toBe(307);
expect(resB.status).toBe(307);
// Exactly one delete-attempt per leg — the atomic shape collapses
// findUnique + delete into a single round-trip.
expect(deleteMock).toHaveBeenCalledTimes(2);
// Exactly one leg landed on `withings=connected`, exactly one
// landed on the replay branch (`withings=error&reason=replay`).
const locations = [resA, resB]
.map((r) => r.headers.get("location"))
.sort();
expect(locations).toHaveLength(2);
expect(
locations.some((l) => l?.includes("withings=connected")),
).toBe(true);
expect(
locations.some((l) => l?.includes("withings=error&reason=replay")),
).toBe(true);
// P2025 must NOT surface as a warning — it's the expected
// replay-branch signal, not a real infra problem.
expect(addWarning).not.toHaveBeenCalled();
// Wide-Event annotation carries the same reason so operators can
// grep without DB-shell.
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "replay" } });
});
it("non-P2025 delete failure (e.g. connection drop) surfaces a Wide-Event warning carrying err.name only, and bounces to the error page", async () => {
// M7 — a real infra failure on the consume-delete must not be
// silently swallowed.
//
// v1.4.49 QA-2 — the warning template now interpolates `err.name`
// rather than `${err}`. A Prisma error whose `message` echoes the
// offending value (e.g. the raw nonce) into the wide-event log
// would otherwise leak it.
class TimeoutError extends Error {
constructor() {
super("connection terminated unexpectedly nonce=abc123");
this.name = "TimeoutError";
}
}
vi.mocked(prisma.withingsOAuthState.delete).mockRejectedValueOnce(
new TimeoutError(),
);
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=state",
);
// The warning carries the error NAME so the audit trail can
// distinguish "row was gone" (P2025, no warning) from "DB unhappy"
// (warning, infra signal) without ever interpolating the message
// body.
expect(addWarning).toHaveBeenCalledTimes(1);
expect(addWarning).toHaveBeenCalledWith(
"oauth-state-delete failed: TimeoutError",
);
// Defensive: the message body (which Prisma may use to echo the
// offending value) must not appear in the warning.
expect(addWarning).not.toHaveBeenCalledWith(
expect.stringContaining("nonce=abc123"),
);
expect(addWarning).not.toHaveBeenCalledWith(
expect.stringContaining("connection terminated"),
);
});
it("delete succeeds but row has expired — validity check trips with reason=expired, no token exchange", async () => {
const expiredRow = {
nonce: NONCE,
userId: "user-1",
expiresAt: new Date(Date.now() - 60_000),
createdAt: new Date(),
};
vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce(
expiredRow as never,
);
const { exchangeCode } = await import("@/lib/withings/client");
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=expired",
);
// Single atomic round-trip consumed the row; no token exchange.
expect(exchangeCode).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "expired" } });
});
it("delete succeeds but row's userId mismatches session — validity check trips with reason=cross_user, no token exchange", async () => {
const crossUserRow = {
nonce: NONCE,
userId: "user-OTHER",
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
createdAt: new Date(),
};
vi.mocked(prisma.withingsOAuthState.delete).mockResolvedValueOnce(
crossUserRow as never,
);
const { exchangeCode } = await import("@/lib/withings/client");
const req = callbackRequest(NONCE, NONCE);
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=cross_user",
);
expect(exchangeCode).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "cross_user" } });
});
it("non-P2025 infra error branch annotates reason=state on the Wide Event (legacy fallback for unclassified DB failures)", async () => {
// v1.4.49 — the four named reasons (csrf1, replay, expired,
// cross_user) cover every "we know what went wrong" branch. A
// raw infra failure is intentionally still tagged `state` —
// operators reading the wide event will see the err.name in the
// warning and the `reason=state` annotation, which is enough to
// distinguish it from the four classified branches.
class TimeoutError extends Error {
constructor() {
super("connection terminated");
this.name = "TimeoutError";
}
}
vi.mocked(prisma.withingsOAuthState.delete).mockRejectedValueOnce(
new TimeoutError(),
);
const req = callbackRequest(NONCE, NONCE);
await GET(req);
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "state" } });
});
it("URL state vs cookie state mismatch short-circuits with reason=csrf1 BEFORE the atomic delete fires", async () => {
// CSRF leg 1 — must reject without touching the ledger so a
// probe can't grief legitimate rows by issuing deletes for
// nonces it doesn't legitimately hold the cookie for.
const req = callbackRequest("aaaaaaaaaaaaaaaaaaaaaa", "bbbbbbbbbbbbbbbbbbbbbb");
const res = await GET(req);
expect(res.status).toBe(307);
expect(res.headers.get("location")).toContain(
"withings=error&reason=csrf1",
);
expect(prisma.withingsOAuthState.delete).not.toHaveBeenCalled();
expect(annotateMock).toHaveBeenCalledWith({ meta: { reason: "csrf1" } });
});
});
+88 -45
View File
@@ -8,6 +8,7 @@ import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { WITHINGS_OAUTH_STATE_COOKIE } from "@/lib/withings/oauth-state";
import { setupWebhook } from "@/lib/withings/sync";
import { markReconnected } from "@/lib/integrations/status";
import { Prisma } from "@/generated/prisma/client";
import { NextRequest, NextResponse } from "next/server";
import { timingSafeEqual } from "node:crypto";
@@ -23,6 +24,17 @@ import { timingSafeEqual } from "node:crypto";
* runs. The row is consumed (deleted) on every exit branch so a
* replay of the same nonce fails the second time.
*
* v1.4.48 M3 — the row is now consumed via a single delete-first
* round-trip. Prisma `delete` returns the row on success, so the
* post-delete validity checks (`expiresAt`, `userId`) still run
* against the consumed payload. Two concurrent callbacks with the
* same nonce now race at the Postgres row level — only one delete
* wins, the other hits P2025 ("record not found") and is treated as
* a replay, bounced to the error page. Previously the `findUnique +
* separate delete` shape left a tiny RTT window where both legs
* could pass the lookup before either issued the delete, contrary
* to the docstring's atomicity promise.
*
* Closes the v1.4.43 security audit L-1 finding: the cookie now
* carries only the opaque nonce, so a future refactor flipping it to
* non-httpOnly cannot leak the user id into request logs / network
@@ -39,13 +51,72 @@ export const GET = apiHandler(async (request: NextRequest) => {
// CSRF check, leg 1: URL state must match the cookie state byte-for-byte.
// Timing-safe comparison so a probe can't recover the cookie bytes via
// response-time analysis.
// response-time analysis. This runs BEFORE the atomic delete so a
// probe with arbitrary state values can't grief legitimate ledger
// rows by triggering deletes for nonces it doesn't legitimately
// hold the cookie for.
//
// v1.4.49 — reason tags now distinguish the four post-delete branches
// so operators reading the audit trail can tell `csrf1` (URL/cookie
// mismatch, short-circuit before delete), `replay` (P2025 — nonce
// already consumed), `expired` (valid row but TTL elapsed), and
// `cross_user` (valid row but session userId mismatch) apart. A
// matching `meta.reason` Wide-Event annotation carries the same so
// operators can grep without DB-shell.
if (
!state ||
!storedState ||
state.length !== storedState.length ||
!timingSafeEqual(Buffer.from(state), Buffer.from(storedState))
) {
annotate({ meta: { reason: "csrf1" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=csrf1",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
// CSRF check, leg 2: atomically consume the ledger row. Prisma
// `delete` returns the row on success so `expiresAt` + `userId`
// checks still run against the consumed payload. P2025 ("record
// not found") means the nonce was already consumed by a prior
// callback (a replay) or never existed — both land on the same
// error redirect. Atomic at the Postgres row level: two concurrent
// callbacks with the same nonce can no longer both pass the
// lookup before either issues the delete.
let stateRow: { userId: string; expiresAt: Date } | null = null;
try {
stateRow = await prisma.withingsOAuthState.delete({
where: { nonce: state },
});
} catch (err) {
if (
err instanceof Prisma.PrismaClientKnownRequestError &&
err.code === "P2025"
) {
// Row already consumed (replay) or never existed.
annotate({ meta: { reason: "replay" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=replay",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
// A real infra problem (connection drop, integrity violation)
// shouldn't be silently swallowed. Surface it to the Wide Event
// so the audit trail captures it, then bounce to the error
// page. Re-throwing would land on the apiHandler's 500 path and
// strand the user without a clean redirect.
//
// v1.4.49 QA-2 — the warning interpolates `err.name` rather than
// `${err}` so a Prisma error message echoing the offending value
// (e.g. the raw nonce) into the wide-event log cannot leak it.
const errName = err instanceof Error ? err.name : "unknown";
getEvent()?.addWarning(`oauth-state-delete failed: ${errName}`);
annotate({ meta: { reason: "state" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=state",
@@ -54,42 +125,30 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
// CSRF check, leg 2: the nonce must resolve to a live, unexpired
// ledger row whose `userId` matches the authenticated session.
// Resolving the user from the row (rather than parsing the cookie
// value) is the v1.4.43 audit recommendation — a future refactor
// flipping the cookie to non-httpOnly cannot leak the user id this
// way.
const stateRow = await prisma.withingsOAuthState.findUnique({
where: { nonce: state },
});
if (
!stateRow ||
stateRow.expiresAt <= new Date() ||
stateRow.userId !== user.id
) {
// Single-use: stamp out the row whether the expiry tripped, the
// user mismatched, or we found nothing. The user will retry from
// `withings/connect` with a fresh nonce.
if (stateRow) {
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
}
// Validity checks against the consumed row. The row is already
// gone from the ledger by this point — single-use semantics —
// whether the expiry tripped, the userId mismatched, or
// everything looked good.
if (stateRow.expiresAt <= new Date()) {
annotate({ meta: { reason: "expired" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=state",
"/settings/integrations?withings=error&reason=expired",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (stateRow.userId !== user.id) {
annotate({ meta: { reason: "cross_user" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=cross_user",
process.env.NEXT_PUBLIC_APP_URL!,
),
);
}
if (!code) {
// Consume the row even on the no-code path so the nonce can't
// be replayed by an attacker who scraped the redirect URL.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocode",
@@ -101,9 +160,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
try {
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=nocreds",
@@ -156,13 +212,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
// don't write a fresh success-time — that's the next sync's job.
await markReconnected(user.id, "withings");
// Consume the ledger row — single-use semantics. From this point
// on a replay of the same `state` lands on the not-found branch
// above and redirects to the error page.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
const response = NextResponse.redirect(
new URL(
"/settings/integrations?withings=connected",
@@ -173,12 +222,6 @@ export const GET = apiHandler(async (request: NextRequest) => {
return response;
} catch (err) {
getEvent()?.setError(err);
// Consume the row on the failure path too — the user is going to
// retry from `withings/connect` which mints a fresh nonce, and a
// stranded row buys us nothing.
await prisma.withingsOAuthState
.delete({ where: { nonce: state } })
.catch(() => {});
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=token",
@@ -0,0 +1,136 @@
/**
* v1.4.48 L5 + L14 — GET /api/withings/connect
*
* Pins:
* 1. Happy path: mints a nonce, creates a `WithingsOAuthState` row,
* redirects to the Withings authorize URL with the state cookie
* attached.
* 2. Rate-limit: the 11th call in a 60 s window redirects to
* `/settings/integrations?withings=error&reason=rate_limited`
* and does not call `prisma.withingsOAuthState.create`.
* 3. Row-create failure: a Prisma error inside the try/catch
* redirects to
* `/settings/integrations?withings=error&reason=connect`
* instead of bubbling a 500.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
requireAuth: vi.fn(async () => ({
user: { id: "u-1" },
session: { id: "s-1" },
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {
withingsOAuthState: {
create: vi.fn(),
},
},
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/withings/credentials", () => ({
getUserWithingsCredentials: vi.fn(),
}));
vi.mock("@/lib/withings/client", () => ({
getAuthorizationUrl: vi.fn(
() => "https://account.withings.com/oauth2_user/authorize2?state=nonce-stub",
),
}));
vi.mock("@/lib/withings/oauth-state", () => ({
WITHINGS_OAUTH_STATE_COOKIE: "withings_oauth_state",
WITHINGS_OAUTH_STATE_TTL_MS: 10 * 60 * 1000,
mintWithingsOAuthStateNonce: vi.fn(() => "nonce-stub"),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => ({
setError: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { checkRateLimit } from "@/lib/rate-limit";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
function connectRequest(): NextRequest {
return new NextRequest("http://localhost/api/withings/connect", {
method: "GET",
});
}
beforeEach(() => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(prisma.withingsOAuthState.create).mockReset();
vi.mocked(prisma.withingsOAuthState.create).mockResolvedValue(
{} as never,
);
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
clientId: "client-id",
clientSecret: "client-secret",
} as never);
});
describe("GET /api/withings/connect", () => {
it("redirects to the Withings authorize URL on the happy path", async () => {
const response = await GET(connectRequest());
expect(response.status).toBe(307);
expect(response.headers.get("location")).toContain(
"account.withings.com/oauth2_user/authorize2",
);
expect(prisma.withingsOAuthState.create).toHaveBeenCalledTimes(1);
expect(checkRateLimit).toHaveBeenCalledWith(
"withings:connect:u-1",
10,
60_000,
);
});
it("redirects to reason=rate_limited when the 11th call within 60 s is rejected", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const response = await GET(connectRequest());
expect(response.status).toBe(307);
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("withings=error");
expect(location).toContain("reason=rate_limited");
expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled();
});
it("redirects to reason=connect when the row create throws", async () => {
vi.mocked(prisma.withingsOAuthState.create).mockRejectedValueOnce(
new Error("Prisma transient failure"),
);
const response = await GET(connectRequest());
expect(response.status).toBe(307);
const location = response.headers.get("location") ?? "";
expect(location).toContain("/settings/integrations");
expect(location).toContain("withings=error");
expect(location).toContain("reason=connect");
});
});
+50 -10
View File
@@ -1,7 +1,8 @@
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { annotate, getEvent } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { getAuthorizationUrl } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import {
@@ -9,7 +10,7 @@ import {
WITHINGS_OAUTH_STATE_TTL_MS,
mintWithingsOAuthStateNonce,
} from "@/lib/withings/oauth-state";
import { NextResponse } from "next/server";
import { NextRequest, NextResponse } from "next/server";
/**
* Redirects the user to Withings OAuth authorization page.
@@ -21,11 +22,39 @@ import { NextResponse } from "next/server";
* nonce, and the callback resolves the user via the row's `userId`
* column. Closes the v1.4.43 security audit L-1 finding where the
* user id was recoverable from log entries / network captures.
*
* v1.4.48 L5 + L14 — endpoint is now rate-limited per-user (10 calls
* per 60 s) so a logged-in user with valid creds can't spam ledger
* rows for the full 10-min TTL window. The row-create call is wrapped
* in try/catch; a transient DB glitch now lands on the existing
* `/settings/integrations?withings=error&reason=connect` surface
* instead of bubbling a 500. Both rate-limit and create-failure paths
* redirect rather than returning JSON because the entry point is a
* browser navigation — a 429 envelope would surface as a blank page.
*/
export const GET = apiHandler(async () => {
const CONNECT_RATE_LIMIT = 10;
const CONNECT_WINDOW_MS = 60_000;
export const GET = apiHandler(async (req: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "withings.connect" } });
const rl = await checkRateLimit(
`withings:connect:${user.id}`,
CONNECT_RATE_LIMIT,
CONNECT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({ action: { name: "withings.connect.rate_limited" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=rate_limited",
req.url,
),
);
}
const creds = await getUserWithingsCredentials(user.id);
if (!creds) {
return apiError(
@@ -35,13 +64,24 @@ export const GET = apiHandler(async () => {
}
const nonce = mintWithingsOAuthStateNonce();
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
try {
await prisma.withingsOAuthState.create({
data: {
nonce,
userId: user.id,
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
},
});
} catch (err) {
getEvent()?.setError(err);
annotate({ action: { name: "withings.connect.create_failed" } });
return NextResponse.redirect(
new URL(
"/settings/integrations?withings=error&reason=connect",
req.url,
),
);
}
const url = getAuthorizationUrl(nonce, creds);
+1 -1
View File
@@ -45,7 +45,7 @@ export function LayoutCoachFab() {
// FAB returns null when the master OR the coach sub-flag is off.
if (!flags.coach) return null;
// v1.4.47 W3 — per-user opt-out beats the operator's "on" default.
// A privacy-leaning user who toggles "Hide Coach" in Settings → AI
// A privacy-leaning user who toggles "Hide Coach" in Settings → Insights
// gets the FAB short-circuited here, identical to the global-off
// branch above. The hook defaults to `false` in the absence of a
// QueryClient (legacy SSR-only snapshot tests).
@@ -130,8 +130,10 @@ describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
it("hint string localises to German", () => {
const html = render(<DashboardLayoutSection id="dashboard-layout" />, "de");
// Anchor on a stable German fragment from the hint copy.
expect(html).toContain("Ziehe den Griff");
// v1.4.48 L9 — copy trimmed to ~12 words so screen readers do
// not spend ~6 s reading the hint on focus. Anchor on the
// shortened phrase.
expect(html).toContain("Pfeiltasten Hoch/Runter");
});
});
@@ -61,7 +61,7 @@ function buildUser(disableCoach: boolean): AuthUser {
import { AiSection } from "../ai-section";
function makeFetch(ok: boolean = true) {
function makeFetch() {
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
const body = init?.body ? JSON.parse(init.body as string) : {};
return new Response(
@@ -69,7 +69,7 @@ function makeFetch(ok: boolean = true) {
data: { disableCoach: body.disableCoach ?? false },
}),
{
status: ok ? 200 : 500,
status: 200,
headers: { "Content-Type": "application/json" },
},
);
@@ -125,31 +125,6 @@ describe("Settings — DisableCoachCard", () => {
expect(html).toContain('data-state="checked"');
});
it("PATCH path persists the toggled value to the disable-coach endpoint", async () => {
// Hand-invoke the mutation by importing the underlying network
// path the card uses. Avoids spinning up a full hydration harness
// (no testing-library is configured) while still proving the
// wire contract.
const fetchSpy = makeFetch();
vi.stubGlobal("fetch", fetchSpy);
const res = await fetch("/api/auth/me/disable-coach", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ disableCoach: true }),
});
expect(res.status).toBe(200);
expect(fetchSpy).toHaveBeenCalledWith(
"/api/auth/me/disable-coach",
expect.objectContaining({
method: "PATCH",
body: JSON.stringify({ disableCoach: true }),
}),
);
const json = (await res.json()) as { data: { disableCoach: boolean } };
expect(json.data.disableCoach).toBe(true);
});
it("Switch description string matches the QoL audit (M2) copy", () => {
const html = render();
// The Marc-voice contract: "Hides the Coach button and drawer
@@ -0,0 +1,77 @@
/**
* v1.4.49 — `<MoodReminderCard>` status auto-clear parity.
*
* The v1.4.48 W-DISABLE-COACH refactor added a 3 s `setTimeout` to
* `<DisableCoachCard>` and claimed in the docstring that the pattern
* mirrored `<MoodReminderCard>`. It did not — the mood-reminder card
* had no auto-clear. v1.4.49 backfills the parity. This suite pins:
*
* 1. The card SSRs cleanly with no in-flight status banner.
* 2. The component module exposes the auto-clear plumbing
* (`scheduleClear` + `clearTimerRef`) so a future refactor can't
* silently regress the docstring claim.
*
* Project convention is SSR-only tests (no `@testing-library/react`);
* the actual timer-tick behaviour is verified through manual QA + the
* shared pattern with `<DisableCoachCard>` which is already audit-grade.
*/
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { renderToStaticMarkup } from "react-dom/server";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/settings/notifications",
useSearchParams: () => new URLSearchParams(""),
}));
import { MoodReminderCard } from "../mood-reminder-card";
function render(): string {
const client = new QueryClient({
defaultOptions: { queries: { retry: 0 } },
});
return renderToStaticMarkup(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<MoodReminderCard isAuthenticated={true} />
</I18nProvider>
</QueryClientProvider>,
);
}
describe("<MoodReminderCard> — v1.4.49 auto-clear parity", () => {
it("SSRs the card scaffolding with no in-flight status banner", () => {
const html = render();
// The Switch primitive surfaces `data-state="unchecked"` for the
// default `moodReminderEnabled: false` profile shape.
expect(html).toContain('data-state="unchecked"');
// No status message visible by default — the `<p role="status">`
// line is gated on `msg !== null`.
expect(html).not.toContain('role="status"');
});
it("contains the auto-clear plumbing introduced for v1.4.48 docstring parity", () => {
// Structural assertion: the source file ships the `scheduleClear`
// helper + `clearTimerRef` (mirroring `<DisableCoachCard>` from
// v1.4.48 M2). Reading the file rather than a runtime mount keeps
// the test deterministic in the SSR-only test environment.
const src = readFileSync(
resolve(__dirname, "../mood-reminder-card.tsx"),
"utf8",
);
expect(src).toContain("clearTimerRef");
expect(src).toContain("scheduleClear");
// 3 s timeout — must match the `<DisableCoachCard>` value so the
// two Settings cards age at the same rate.
expect(src).toMatch(/setTimeout\([^,]+,\s*3000\)/);
// Cleanup-on-unmount branch — without this a navigate-away during
// the 3 s window would leave a stray timer pointing at unmounted
// state.
expect(src).toContain("clearTimeout(clearTimerRef.current)");
});
});
+25 -46
View File
@@ -14,9 +14,9 @@ import {
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { queryKeys } from "@/lib/query-keys";
interface VersionPayload {
@@ -148,53 +148,32 @@ export function AboutSection() {
// sources/docs links. Settings → Account still has its own
// "Restart onboarding tour" — both buttons share the same flow.
const [replayingTour, setReplayingTour] = useState(false);
const [tourMessage, setTourMessage] = useState<string | null>(null);
const [tourMessageType, setTourMessageType] = useState<
"success" | "error" | null
>(null);
// v1.4.48 M6c — text + type collapsed into one discriminated state so
// the two values can never drift (a "success" message rendered in the
// destructive colour was the latent bug class).
const [tourFeedback, setTourFeedback] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
async function handleReplayTour() {
setReplayingTour(true);
setTourMessage(null);
setTourMessageType(null);
try {
const res = await fetch("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
setTourFeedback(null);
// v1.4.48 M6b — both Settings → Account and Settings → About now
// delegate to the shared `restartOnboardingTour()` worker so the
// server flip + force-launch marker + window event live in one
// place. The helper returns a discriminated result; this surface
// only owns the translation + feedback rendering.
const result = await restartOnboardingTour(user?.id);
if (result.ok) {
setTourFeedback({
text: t("onboarding.tour.restartConfirmation"),
type: "success",
});
if (!res.ok) {
setTourMessage(t("settings.savingError"));
setTourMessageType("error");
return;
}
// v1.4.47 W5 — drop a per-user force-launch marker into
// sessionStorage so the next dashboard mount opens the tour
// even when the 24 h auto-launch gate would otherwise suppress
// it (e.g. a first-day user who clicked "Replay the tour"
// from About 30 min after finishing the wizard). The marker is
// session-scoped — a page reload clears it — and consumed
// exactly once by the launcher on its next mount.
if (user?.id) setTourForceLaunch(user.id);
// Fire the same window event Settings → Account uses; a
// dashboard already mounted in another tab / background reopens
// the spotlight immediately. When the user lands on the
// dashboard next, the launcher reads the freshly-flipped
// `onboardingTourCompleted = false` from `/api/auth/me` and the
// force-launch marker set above; either path lands on the tour.
try {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
} catch {
/* ignore — only matters if a dashboard is already mounted */
}
setTourMessage(t("onboarding.tour.restartConfirmation"));
setTourMessageType("success");
} catch {
setTourMessage(t("common.networkError"));
setTourMessageType("error");
} finally {
setReplayingTour(false);
} else {
setTourFeedback({ text: t(result.messageKey), type: "error" });
}
setReplayingTour(false);
}
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
@@ -423,16 +402,16 @@ export function AboutSection() {
{t("settings.about.tourReplay")}
</Button>
</div>
{tourMessage && (
{tourFeedback && (
<p
role="alert"
className={`mt-2 text-xs ${
tourMessageType === "success"
tourFeedback.type === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{tourMessage}
{tourFeedback.text}
</p>
)}
</div>
+29 -44
View File
@@ -39,11 +39,11 @@ import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { NativeSelect } from "@/components/ui/native-select";
import { PasswordInput } from "@/components/ui/password-input";
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { formatDate } from "@/lib/format";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { useTranslations } from "@/lib/i18n/context";
import { restartOnboardingTour } from "@/lib/onboarding/tour-restart";
import { describePasskeyError } from "@/lib/passkey-errors";
import { queryKeys } from "@/lib/query-keys";
import { TimezonePicker } from "@/components/settings/timezone-picker";
@@ -135,12 +135,15 @@ export function AccountSection() {
// dispatches a `healthlog:tour-restart` window event so the dashboard's
// <TourLauncher> picks it up immediately on the user's next nav. The
// confirmation message goes through the same announce channel as
// every other settings save (status: "success" | "error" | null).
// every other settings save.
// v1.4.48 M6c — text + type collapsed into one discriminated state so
// the two values can never drift (same shape as `<AboutSection>`'s
// replay button so the surface stays mirrored).
const [tourRestarting, setTourRestarting] = useState(false);
const [tourMsg, setTourMsg] = useState<string | null>(null);
const [tourMsgType, setTourMsgType] = useState<"success" | "error" | null>(
null,
);
const [tourFeedback, setTourFeedback] = useState<{
text: string;
type: "success" | "error";
} | null>(null);
// Passkey registration state.
const [passkeyLoading, setPasskeyLoading] = useState(false);
@@ -280,43 +283,25 @@ export function AccountSection() {
async function handleRestartTour() {
setTourRestarting(true);
setTourMsg(null);
setTourMsgType(null);
try {
const res = await fetch("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
});
if (!res.ok) {
setTourMsg(t("settings.savingError"));
setTourMsgType("error");
return;
}
// v1.4.47 W5 — drop a per-user force-launch marker in
// sessionStorage so the next dashboard mount launches the tour
// even when the new 24 h auto-launch gate would otherwise
// suppress it (e.g. user just finished the wizard 5 min ago).
// The launcher reads-and-clears the marker on mount.
if (user?.id) setTourForceLaunch(user.id);
// Refetch the auth payload so `<TourLauncher>` re-evaluates
// with the new flag, AND fire a window event so a launcher
// already mounted on the dashboard reopens immediately
// without waiting for a navigation.
setTourFeedback(null);
// v1.4.48 M6b — both Settings → Account and Settings → About now
// delegate to the shared `restartOnboardingTour()` worker so the
// server flip + force-launch marker + window event live in one
// place. Account additionally refetches the auth payload so the
// `onboardingTourCompleted` flag the launcher reads matches the
// server flip immediately — About has no auth handle to refetch
// and relies on the next navigation re-running `/api/auth/me`.
const result = await restartOnboardingTour(user?.id);
if (result.ok) {
await refetch();
try {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
} catch {
/* ignore — only matters if the dashboard is already mounted */
}
setTourMsg(t("onboarding.tour.restartConfirmation"));
setTourMsgType("success");
} catch {
setTourMsg(t("common.networkError"));
setTourMsgType("error");
} finally {
setTourRestarting(false);
setTourFeedback({
text: t("onboarding.tour.restartConfirmation"),
type: "success",
});
} else {
setTourFeedback({ text: t(result.messageKey), type: "error" });
}
setTourRestarting(false);
}
async function handleChangePassword(e: React.FormEvent) {
@@ -631,16 +616,16 @@ export function AccountSection() {
{t("onboarding.tour.restart")}
</Button>
</div>
{tourMsg && (
{tourFeedback && (
<p
role="alert"
className={`mt-2 text-xs ${
tourMsgType === "success"
tourFeedback.type === "success"
? "text-dracula-green"
: "text-destructive"
}`}
>
{tourMsg}
{tourFeedback.text}
</p>
)}
</div>
+35 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -198,6 +198,34 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// v1.4.48 M2 — auto-clear the inline `<p role="status">` line 3 s
// after the mutation settles so a Settings card the user scrolled
// past minutes earlier doesn't keep echoing a stale "Coach hidden"
// banner. The ref tracks the in-flight timer so we can clear it on
// unmount + on a follow-up toggle (otherwise a rapid double-tap
// could leave a stray timer pointing at a stale message).
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const checked = optimistic ?? user?.disableCoach ?? false;
const mutation = useMutation({
@@ -227,6 +255,7 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
// re-fetches /api/auth/me with the updated `disableCoach` field.
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
setOptimistic(null);
scheduleClear();
},
onError: (err) => {
setOptimistic(null);
@@ -236,6 +265,7 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
: t("settings.ai.disableCoach.saveError"),
);
setMsgType("error");
scheduleClear();
},
});
@@ -243,6 +273,10 @@ function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
setOptimistic(next);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
mutation.mutate(next);
}
@@ -29,6 +29,7 @@ import {
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import { prefersReducedMotion } from "@/lib/charts/reduced-motion";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import {
@@ -52,6 +53,10 @@ import {
* shape (`order: 0..n-1`) so the existing PUT contract stays untouched
* and either input mode flushes via the same Save mutation. Exported so
* the unit test can pin the contract without spinning up a DndContext.
*
* v1.4.48 M6a — also drives the arrow-button `move()` handler below;
* the previous in-file swap-and-renumber implementation was a second
* copy of the same logic.
*/
export function reorderWidgets(
widgets: readonly { id: string; order: number }[],
@@ -70,6 +75,43 @@ export function reorderWidgets(
return next.map((w, i) => ({ ...w, order: i }));
}
/**
* v1.4.48 M6a — merge the `{ id, order }` shape returned by
* `reorderWidgets()` back into the full `DashboardWidgetConfig[]` so
* the section's draft state keeps every per-row flag (`visible`,
* `tileVisible`) while the order is rewritten. The arrow buttons and
* the @dnd-kit drag-end handler both flow through this helper so
* neither surface can silently drop a flag.
*/
function mergeReorderIntoLayout(
widgets: DashboardLayout["widgets"],
reordered: readonly { id: string; order: number }[],
): DashboardLayout["widgets"] {
const byId = new Map(widgets.map((w) => [w.id, w]));
return reordered.map((r, i) => {
const original = byId.get(r.id as DashboardWidgetId);
if (!original) {
// v1.4.49 — defence-in-depth dev warning for the orphan branch.
// Today this branch is statically unreachable: every id in
// `reordered` is sourced from `layout.widgets`, so `byId.get`
// always hits. The upcoming per-tile Suspense refactor will
// introduce dynamic widgets where this invariant could break;
// the warning fires in dev only so a regression surfaces in the
// console instead of silently dropping the row via the cast.
if (
typeof window !== "undefined" &&
process.env.NODE_ENV === "development"
) {
console.warn(
`mergeReorderIntoLayout: orphan widget id "${r.id}" dropped`,
);
}
return { ...r, order: i } as never;
}
return { ...original, order: i };
});
}
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
weight: "dashboard.weight",
bp: "dashboard.bloodPressure",
@@ -199,16 +241,25 @@ export function DashboardLayoutSection({ id }: { id: string }) {
setDraft({ ...layout, comparisonBaseline: value });
}
/**
* v1.4.48 M6a — the arrow buttons now delegate to `reorderWidgets()`
* so the swap-and-renumber logic lives in exactly one place. The
* neighbour id is derived from the sorted layout index + delta;
* out-of-bounds clicks (top row + ArrowUp, bottom row + ArrowDown)
* short-circuit before the helper sees them so the button disabled
* state stays the single source of truth for the boundary.
*/
function move(widgetId: DashboardWidgetId, delta: -1 | 1) {
if (!layout) return;
const sorted = [...layout.widgets].sort((a, b) => a.order - b.order);
const idx = sorted.findIndex((w) => w.id === widgetId);
const targetIdx = idx + delta;
if (idx < 0 || targetIdx < 0 || targetIdx >= sorted.length) return;
[sorted[idx], sorted[targetIdx]] = [sorted[targetIdx], sorted[idx]];
const neighbourId = sorted[targetIdx].id;
const reordered = reorderWidgets(layout.widgets, widgetId, neighbourId);
setDraft({
...layout,
widgets: sorted.map((w, i) => ({ ...w, order: i })),
widgets: mergeReorderIntoLayout(layout.widgets, reordered),
});
}
@@ -229,17 +280,9 @@ export function DashboardLayoutSection({ id }: { id: string }) {
String(active.id),
String(over.id),
);
// `reorderWidgets` operates on the pure { id, order } shape; map back
// to the full DashboardWidgetConfig so `visible` / `tileVisible` are
// preserved on every row.
const byId = new Map(layout.widgets.map((w) => [w.id, w]));
setDraft({
...layout,
widgets: reordered.map((r, i) => {
const original = byId.get(r.id as DashboardWidgetId);
if (!original) return { ...r, order: i } as never;
return { ...original, order: i };
}),
widgets: mergeReorderIntoLayout(layout.widgets, reordered),
});
}
@@ -410,10 +453,14 @@ export function DashboardLayoutSection({ id }: { id: string }) {
{/* v1.4.47 W4 — single shared aria-describedby target for all
drag handles. Screen readers read this once per focused
handle; sighted users see it in the native browser tooltip
via the matching `title` attribute on each handle. */}
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
via the matching `title` attribute on each handle.
v1.4.48 L8 — gate on widget count so an empty layout never
orphans the paragraph (no handles to describe). */}
{layout.widgets.length > 0 && (
<p id={dragHintId} className="text-muted-foreground sr-only">
{t("dashboard.dragHandleHint")}
</p>
)}
</div>
)}
@@ -509,9 +556,15 @@ function SortableWidgetRow({
isDragging,
} = useSortable({ id: widget.id });
// v1.4.48 L7 — honour the OS `prefers-reduced-motion` preference. The
// rest of HealthLog pairs every transition with a `motion-reduce`
// companion; dnd-kit's default `transform 250ms ease` was the lone
// surface that ignored it. Short-circuit to `none` when reduced
// motion is requested so dragged rows snap to place instead of
// sliding through the list.
const style: React.CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
transition: prefersReducedMotion() ? "none" : transition,
};
const tileChecked =
+38 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { SmilePlus } from "lucide-react";
@@ -25,6 +25,37 @@ export function MoodReminderCard({
const [msg, setMsg] = useState<string | null>(null);
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
// v1.4.49 — auto-clear the inline `<p role="status">` line 3 s after
// the mutation settles so a Settings card the user scrolled past
// minutes earlier doesn't keep echoing a stale "reminder enabled"
// banner. Mirrors the `<DisableCoachCard>` pattern landed in v1.4.48
// M2; the v1.4.48 docstring claimed this card already auto-cleared,
// this commit backfills the actual behaviour. The ref tracks the
// in-flight timer so we can clear it on unmount + on a follow-up
// toggle (otherwise a rapid double-tap could leave a stray timer
// pointing at a stale message).
const clearTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
};
}, []);
function scheduleClear() {
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
}
clearTimerRef.current = setTimeout(() => {
clearTimerRef.current = null;
setMsg(null);
setMsgType(null);
}, 3000);
}
const { data: profile } = useQuery({
queryKey: queryKeys.userProfile(),
queryFn: async () => {
@@ -42,6 +73,10 @@ export function MoodReminderCard({
setSaving(true);
setMsg(null);
setMsgType(null);
if (clearTimerRef.current !== null) {
clearTimeout(clearTimerRef.current);
clearTimerRef.current = null;
}
const res = await fetch("/api/user/profile", {
method: "PATCH",
@@ -58,10 +93,12 @@ export function MoodReminderCard({
setMsgType("success");
await queryClient.invalidateQueries({ queryKey: queryKeys.userProfile() });
setOptimistic(null);
scheduleClear();
} else {
setOptimistic(null);
setMsg(t("notifications.moodReminder.saveError"));
setMsgType("error");
scheduleClear();
}
setSaving(false);
}
@@ -63,20 +63,6 @@ describe("<Textarea>", () => {
expect(html).toContain("sm:text-sm");
});
it("is wrapped in React.forwardRef so refs reach the <textarea>", () => {
// React tags forwardRef components with a sentinel symbol on
// `$$typeof`. Verifying the sentinel keeps the assertion
// dependency-free (no testing-library / DOM env needed) while
// still catching a regression that strips the wrapper.
const forwardRefSymbol = Symbol.for("react.forward_ref");
const componentMeta = Textarea as unknown as {
$$typeof: symbol;
render?: unknown;
};
expect(componentMeta.$$typeof).toBe(forwardRefSymbol);
expect(typeof componentMeta.render).toBe("function");
});
it("forwards rows / placeholder / maxLength to the underlying element", () => {
const html = renderToStaticMarkup(
<Textarea
@@ -0,0 +1,33 @@
"use client";
import { useContext } from "react";
import { QueryClientContext } from "@tanstack/react-query";
/**
* v1.4.48 M4 — shared SSR-safety helper for hooks that delegate to
* React Query under a `<QueryClientProvider>`.
*
* A long tail of legacy presentational components (`<HeroStrip>`,
* `<SuggestedPrompts>`, `<LayoutCoachFab>`, `<InsightStatusCard>`,
* `<HealthScoreDeltaExplainer>`, …) ships with unit tests that render
* the component in isolation without wrapping it in
* `<QueryClientProvider>`. Hooks that call `useQuery` directly crash
* in that environment.
*
* `useQueryClientMounted()` returns `true` when a `QueryClientContext`
* value is present in the React tree, `false` otherwise. The branch is
* stable across the component's lifetime — the provider never
* appears and disappears between renders — so a conditional hook call
* gated on this value remains safe per the Rules of Hooks (the same
* posture the React docs document for "different environments where
* the same component renders").
*/
export function useQueryClientMounted(): boolean {
// The cast strips the generic so callers don't have to learn the
// internal `QueryClient | undefined` shape — they only need to know
// whether the context is mounted.
const ctx = useContext(
QueryClientContext as unknown as React.Context<unknown>,
);
return ctx != null;
}
+9 -11
View File
@@ -1,9 +1,7 @@
"use client";
import { useContext } from "react";
import { QueryClientContext } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
/**
* v1.4.47 W3 — SSR-safe accessor for the per-user "Hide Coach" toggle.
@@ -15,16 +13,16 @@ import { useAuth } from "@/hooks/use-auth";
* long-tail SSR snapshot tests (`<SuggestedPrompts>`, `<LayoutCoachFab>`,
* `<HeroStrip>` …) render the component in isolation and don't.
*
* Mirrors the defensive pattern in `useFeatureFlags()`: when no
* `QueryClientContext` is mounted we short-circuit to the "no opt-out"
* default so the legacy tests keep rendering the surface, and the gate
* stays correct for the production app where the provider is always
* mounted.
* v1.4.48 M4 — share the `<QueryClientProvider>`-mount detection with
* `useFeatureFlags()` via the `_internal/use-query-client-safe`
* helper. When no client is mounted we short-circuit to the "no
* opt-out" default; the inner hook is only invoked once the provider
* is known to be in the tree, so the conditional call is safe per the
* Rules of Hooks (the branch is stable across the component's
* lifetime).
*/
export function useDisableCoach(): boolean {
const hasClient = useContext(
QueryClientContext as unknown as React.Context<unknown>,
);
const hasClient = useQueryClientMounted();
if (!hasClient) return false;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useDisableCoachInner();
+7 -4
View File
@@ -1,9 +1,9 @@
"use client";
import { useContext } from "react";
import { QueryClientContext, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query-keys";
import { useQueryClientMounted } from "@/hooks/_internal/use-query-client-safe";
/**
* v1.4.31 — Client-side accessor for the operator's assistant
@@ -77,10 +77,13 @@ async function fetchFeatureFlags(): Promise<FeatureFlagsPayload> {
* renders — so the conditional hook call is safe per the Rules of
* Hooks (same posture the React docs document for "different
* environments where the same component renders").
*
* v1.4.48 M4 — the SSR-mount probe is shared with `useDisableCoach`
* via `_internal/use-query-client-safe` so the eslint-disable line
* stays in exactly one place per consumer.
*/
export function useFeatureFlags(): AssistantFlagSet {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hasClient = useContext(QueryClientContext as unknown as React.Context<any>);
const hasClient = useQueryClientMounted();
if (!hasClient) return DEFAULT_ASSISTANT_FLAGS;
// eslint-disable-next-line react-hooks/rules-of-hooks
return useFeatureFlagsQuery();
@@ -123,4 +123,45 @@ describe("returnAllZodIssues", () => {
// from the JSON envelope, matching apiError's contract.
expect(body.meta?.headers).toBeUndefined();
});
// v1.4.49 — opt-in `stripValuesFromMessage` mode for audit-ledger
// writes. The default Zod message for `invalid_enum_value` and a
// handful of other codes embeds the offending value verbatim; an
// audit-row write on a free-text route would persist user content.
// The opt-in keeps the additive multi-issue envelope shape but
// drops `message` entirely so only `path` + `code` survive.
describe("sanitiseZodIssues with stripValuesFromMessage", () => {
it("returns { path, code } only when the option is set", () => {
const enumSchema = z.object({
kind: z.enum(["weight", "pulse"]),
});
const parsed = enumSchema.safeParse({ kind: "garbage-value-xxx" });
if (parsed.success) throw new Error("expected failure");
const stripped = sanitiseZodIssues(parsed.error.issues, {
stripValuesFromMessage: true,
});
expect(stripped.length).toBe(1);
for (const issue of stripped) {
expect(Object.keys(issue).sort()).toEqual(["code", "path"]);
// The offending value must not survive on any returned key.
expect(JSON.stringify(issue)).not.toContain("garbage-value-xxx");
}
});
it("default behaviour (no option) keeps message intact", () => {
const enumSchema = z.object({
kind: z.enum(["weight", "pulse"]),
});
const parsed = enumSchema.safeParse({ kind: "garbage" });
if (parsed.success) throw new Error("expected failure");
const kept = sanitiseZodIssues(parsed.error.issues);
expect(kept[0]).toMatchObject({
path: "kind",
code: expect.any(String),
message: expect.any(String),
});
});
});
});
@@ -54,10 +54,15 @@ afterEach(() => {
describe("computeSummariesSlice", () => {
describe("cold fallback — empty rollup table", () => {
it("returns the empty-summary skeleton when the user has no rows", async () => {
// v1.4.48 M0 — cold path now issues two aggregate queries
// (`allTime` + `windowed`) instead of one fat aggregate, so the
// $queryRaw call count is 4 instead of 3:
// 1. per-type coverage probe — empty ⇒ cold path.
// 2. heavy aggregate ($queryRaw) — empty.
// 3. latests ($queryRaw) — empty.
// 2. all-time aggregate ($queryRaw) — empty.
// 3. windowed aggregate ($queryRaw, 90-day cap) — empty.
// 4. latests ($queryRaw) — empty.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([]);
@@ -79,11 +84,16 @@ describe("computeSummariesSlice", () => {
avg30LastYear: null,
});
expect(result.bmi).toBeNull();
expect(RAW).toHaveBeenCalledTimes(3);
expect(RAW).toHaveBeenCalledTimes(4);
});
it("maps a populated heavy aggregate row into the DataSummary shape on cold path", async () => {
// Coverage probe returns WEIGHT with no buckets → cold path.
// v1.4.48 M0 — cold path now splits the heavy aggregate into
// all-time + 90-day-capped windowed queries. Mock order:
// 1. coverage probe (WEIGHT uncovered → cold path)
// 2. all-time aggregate (count / min / max / mean)
// 3. windowed aggregate (avg7/30 + slope/r²)
// 4. latests
RAW.mockResolvedValueOnce([{ type: "WEIGHT", has_buckets: false }])
.mockResolvedValueOnce([
{
@@ -92,6 +102,11 @@ describe("computeSummariesSlice", () => {
min_value: 79.2,
max_value: 84.1,
mean_value: 82.05,
},
])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: 81.9,
avg30: 82.1,
slope7: -0.014,
@@ -145,6 +160,11 @@ describe("computeSummariesSlice", () => {
min_value: 72,
max_value: 72,
mean_value: 72,
},
])
.mockResolvedValueOnce([
{
type: "PULSE",
avg7: 72,
avg30: 72,
slope7: null,
@@ -175,6 +195,11 @@ describe("computeSummariesSlice", () => {
min_value: 80,
max_value: 84,
mean_value: 82,
},
])
.mockResolvedValueOnce([
{
type: "WEIGHT",
avg7: null,
avg30: 82,
slope7: null,
@@ -207,6 +232,11 @@ describe("computeSummariesSlice", () => {
min_value: 60,
max_value: 95,
mean_value: 77,
},
])
.mockResolvedValueOnce([
{
type: "PULSE",
avg7: 77,
avg30: 77,
slope7: 0,
@@ -436,8 +466,11 @@ describe("computeSummariesSlice", () => {
});
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.
// v1.4.48 M0 — empty coverage map ⇒ `isFullyCovered` returns
// false ⇒ cold-fallback path. Cold path now issues 4 raw
// queries (coverage probe + all-time + windowed + latests),
// all returning empty arrays here ⇒ no types-with-data ⇒ the
// year-ago probe must NOT fan out.
RAW.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
.mockResolvedValueOnce([])
@@ -448,5 +481,48 @@ describe("computeSummariesSlice", () => {
expect(ROLLUP_FIND_MANY).not.toHaveBeenCalled();
});
});
/**
* v1.4.48 M0 — pins the 90-day outer `measured_at` cap on the two
* windowed measurements scans: the `narrows` query inside the
* rollup-fresh happy path and the `windowed` query inside the
* cold-fallback path. Both must constrain the outer WHERE to the
* 90-day suffix so the planner does an index range scan on
* `(user_id, type, measured_at)` instead of a full-partition scan.
* If a future refactor drops the cap, this assertion fails before
* the perf regression reaches main.
*/
describe("90-day outer measured_at cap (v1.4.48 M0)", () => {
it("applies the 90-day cap to narrows (rollup-fresh path) and windowed (cold-fallback path)", async () => {
const queries: string[] = [];
RAW.mockImplementation((strings: TemplateStringsArray) => {
queries.push(strings.join("?"));
return Promise.resolve([]);
});
// Trigger the rollup-fresh path so we capture the `narrows` SQL.
// Coverage probe returns one covered type ⇒ `isFullyCovered`
// is true ⇒ `computeFromRollups` runs.
RAW.mockImplementationOnce((strings: TemplateStringsArray) => {
queries.push(strings.join("?"));
return Promise.resolve([{ type: "WEIGHT", has_buckets: true }]);
});
await computeSummariesSlice("user-rollup-pin");
// Trigger the cold-fallback path so we capture the `windowed`
// SQL. Empty coverage map ⇒ `isFullyCovered` is false ⇒
// `computeFromLiveAggregate` runs.
await computeSummariesSlice("user-cold-pin");
const joined = queries.join("\n---\n");
// Both windowed scans must carry the outer 90-day cap.
const capMatches = joined.match(
/AND m\."measured_at" >= NOW\(\) - INTERVAL '90 days'/g,
);
expect(capMatches).not.toBeNull();
// narrows (rollup-fresh) + windowed (cold-fallback) = 2 caps.
expect(capMatches?.length).toBeGreaterThanOrEqual(2);
});
});
});
+67 -34
View File
@@ -71,24 +71,17 @@ import { readBestGranularityRollups } from "@/lib/rollups/measurement-read-wmy";
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Heavy aggregate row — used on the cold-mount fallback path where the
* rollup table is empty and we need every per-type column out of one
* SQL pass for the very first request.
* v1.4.48 — all-time aggregate row used on the cold-mount fallback
* path. Holds the linearly composable columns (`count / min / max /
* mean`) that must reflect every row the user has ever logged for
* that type, so this query intentionally has no `measured_at` cap.
*/
interface HeavyAggregateRow {
interface AllTimeAggregateRow {
type: string;
count: bigint;
min_value: number | null;
max_value: number | null;
mean_value: number | null;
avg7: number | null;
avg30: number | null;
slope7: number | null;
r2_7: number | null;
slope30: number | null;
r2_30: number | null;
slope90: number | null;
r2_90: number | null;
}
/**
@@ -100,8 +93,9 @@ interface HeavyAggregateRow {
/**
* v1.4.37.2 — shape returned by the per-type GROUP BY over
* `measurement_rollups`. Replaces the v1.4.35 row-per-bucket transfer
* that materialised ~306k rows on a power-user account; the SQL now
* does the aggregation server-side and hands back one row per type.
* that materialised a six-figure row count on tenants with large
* measurement partitions; the SQL now does the aggregation
* server-side and hands back one row per type.
*/
interface RollupAggregateRow {
type: string;
@@ -201,8 +195,8 @@ export async function computeSummariesSlice(
): Promise<SummariesSlice> {
// v1.4.37.1 hotfix — fire-and-forget. See `src/app/api/analytics/route.ts`
// for the full rationale: awaiting this on the read path can stall
// the event loop for 30–60 s on power-user accounts whose iOS step
// samples keep the 90-day window slightly stale. The downstream
// the event loop for tens of seconds on tenants with large iOS step
// sample sets that keep the 90-day window slightly stale. The downstream
// coverage probe falls back to live SQL for uncovered types, so
// correctness is preserved; the read just doesn't block waiting
// for the background refresh.
@@ -257,9 +251,9 @@ export async function computeSummariesSlice(
* `(user_id, type, measured_at)` instead of reading every row in the
* user's measurements partition. Every FILTER expression inside the
* SELECT already restricts to 7/30/90 days, so the cap excludes only
* rows that were already being aggregated to NULL. On a power-user
* account with ~450k all-time rows this lifts the slim slice from
* ~9 s cold to ~0.5-1 s; output is bit-identical.
* rows that were already being aggregated to NULL. On tenants with
* large measurement partitions this lifts the slim slice from
* multi-second cold to sub-second; output is bit-identical.
*/
async function computeFromRollups(userId: string): Promise<SummariesSlice> {
const [narrows, latests, dayBuckets] = await Promise.all([
@@ -327,11 +321,12 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
// v1.4.37.2 hotfix — the v1.4.35 implementation read EVERY DAY
// rollup bucket for the user (`findMany` without a `bucketStart`
// window) and then composed `count / min / max / mean` in JS.
// On a power-user account that materialised as a 306k-row
// transfer + a JS loop = ~3.85 s per cache miss, even with the
// rollup table hot. The slim slice's contract is the all-time
// count / min / max / mean per type — exactly what a SQL
// `GROUP BY type` returns in a single round-trip. Returns 8 rows
// On tenants with large measurement partitions that materialised
// as a six-figure row transfer + a JS loop = multi-second per
// cache miss, even with the rollup table hot. The slim slice's
// contract is the all-time count / min / max / mean per type —
// exactly what a SQL `GROUP BY type` returns in a single
// round-trip. Returns 8 rows
// instead of 306k, brings the cache-miss cost into the < 100 ms
// budget. The downstream `aggregateBuckets` call is bypassed
// because the per-type aggregate is already shaped server-side.
@@ -461,18 +456,44 @@ async function computeFromRollups(userId: string): Promise<SummariesSlice> {
* Cold fallback — runs the legacy heavy aggregate when the rollup
* table is empty for this user. Subsequent reads pick up the populated
* rollup on `computeFromRollups`.
*
* v1.4.48 — split into two parallel queries:
*
* 1. `allTime` — the linearly composable columns (`count / min /
* max / mean`) keep the full-partition scan because they must
* reflect every row the user has ever logged. No `measured_at`
* cap.
*
* 2. `windowed` — `avg7 / avg30` and the slope/r² tuples are all
* already filtered to a 7/30/90 day window inside the SELECT.
* Adding an outer `measured_at >= NOW() - INTERVAL '90 days'`
* cap lets the planner do an index range scan on
* `(user_id, type, measured_at)` instead of a full-partition
* sequential scan and discarding 95 % of rows inside FILTER
* clauses. On tenants with large measurement partitions the
* cold fallback drops from multi-second to sub-second. Output is
* bit-identical because every row excluded by the new outer cap
* was already aggregating to NULL inside its FILTER clause.
*/
async function computeFromLiveAggregate(
userId: string,
): Promise<SummariesSlice> {
const [aggregates, latests] = await Promise.all([
prisma.$queryRaw<HeavyAggregateRow[]>`
const [allTime, windowed, latests] = await Promise.all([
prisma.$queryRaw<AllTimeAggregateRow[]>`
SELECT
m."type"::text AS type,
COUNT(*) AS count,
MIN(m."value")::double precision AS min_value,
MAX(m."value")::double precision AS max_value,
AVG(m."value")::double precision AS mean_value,
AVG(m."value")::double precision AS mean_value
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
GROUP BY m."type"
`,
prisma.$queryRaw<NarrowAggregateRow[]>`
SELECT
m."type"::text AS type,
AVG(m."value") FILTER (
WHERE m."measured_at" >= NOW() - INTERVAL '7 days'
)::double precision AS avg7,
@@ -518,6 +539,7 @@ async function computeFromLiveAggregate(
FROM measurements m
WHERE m."user_id" = ${userId}
AND m."deleted_at" IS NULL
AND m."measured_at" >= NOW() - INTERVAL '90 days'
GROUP BY m."type"
`,
prisma.$queryRaw<LatestRow[]>`
@@ -562,24 +584,35 @@ async function computeFromLiveAggregate(
};
}
// v1.4.48 — windowed columns are keyed by type alongside the
// all-time aggregate. Types that exist in `allTime` but have no
// measurements in the 90-day window leave their windowed columns at
// `null` (the same shape the pre-split query produced via FILTER
// returning NULL on an empty set).
const windowedByType = new Map<string, NarrowAggregateRow>();
for (const row of windowed) {
windowedByType.set(row.type, row);
}
let totalRows = 0;
const typesWithData: string[] = [];
for (const row of aggregates) {
for (const row of allTime) {
const count = Number(row.count);
totalRows += count;
typesWithData.push(row.type);
const latest = latestByType.get(row.type) ?? null;
const win = windowedByType.get(row.type);
summaries[row.type] = {
count,
latest,
min: round2(row.min_value),
max: round2(row.max_value),
mean: round2(row.mean_value),
avg7: round2(row.avg7),
avg30: round2(row.avg30),
slope7: buildSlope(row.slope7, row.r2_7),
slope30: buildSlope(row.slope30, row.r2_30),
slope90: buildSlope(row.slope90, row.r2_90),
avg7: round2(win?.avg7 ?? null),
avg30: round2(win?.avg30 ?? null),
slope7: buildSlope(win?.slope7 ?? null, win?.r2_7 ?? null),
slope30: buildSlope(win?.slope30 ?? null, win?.r2_30 ?? null),
slope90: buildSlope(win?.slope90 ?? null, win?.r2_90 ?? null),
anomalyCount: 0,
avg30LastMonth: null,
avg30LastYear: null,
@@ -609,7 +642,7 @@ async function computeFromLiveAggregate(
analytics: {
slim_summaries: {
row_count: totalRows,
type_count: aggregates.length,
type_count: allTime.length,
path: "live",
year_over_year_types: yearOverYearTypeCount,
},
+84 -1
View File
@@ -18,6 +18,25 @@ export interface SanitisedZodIssue {
message: string;
}
/**
* Variant used when the issues land in an audit-ledger row whose
* source field is free-text. `invalid_enum_value` and a handful of
* other Zod codes embed the offending value verbatim in
* `issue.message`, so a route that JSON-stringifies the sanitised
* array into `details` can leak user content. Callers opt in via
* `sanitiseZodIssues(error.issues, { stripValuesFromMessage: true })`.
*/
export interface SanitiseZodIssueOptions {
/**
* When true, drop `issue.message` from the returned shape entirely.
* Only `path` + `code` survive — enough for an operator to triage
* the rejection without persisting user-typed content. Default is
* `false` so the additive multi-issue envelope keeps its existing
* client contract.
*/
stripValuesFromMessage?: boolean;
}
/**
* v1.4.42 W2 — multi-issue Zod error envelope.
*
@@ -33,10 +52,32 @@ export interface SanitisedZodIssue {
* Privacy: only `path`, `code` and `message` are echoed. `issue.params`
* (which can carry the raw rejected value for some Zod issue codes)
* stays server-side.
*
* v1.4.49 — `stripValuesFromMessage` removes `message` for the
* auditLog-emission sites whose input fields are free-text. Some Zod
* codes (e.g. `invalid_enum_value`) embed the offending value in the
* default message string; a free-text route writing the message into
* the audit ledger would leak user content. The opt-in is additive —
* the default behaviour is unchanged so existing clients reading
* `details.issues[*].message` over the wire keep working.
*/
export function sanitiseZodIssues(
issues: readonly ZodIssue[],
): SanitisedZodIssue[] {
): SanitisedZodIssue[];
export function sanitiseZodIssues(
issues: readonly ZodIssue[],
options: { stripValuesFromMessage: true },
): Array<Omit<SanitisedZodIssue, "message">>;
export function sanitiseZodIssues(
issues: readonly ZodIssue[],
options?: SanitiseZodIssueOptions,
): SanitisedZodIssue[] | Array<Omit<SanitisedZodIssue, "message">> {
if (options?.stripValuesFromMessage) {
return issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
}));
}
return issues.map((issue) => ({
path: issue.path.join("."),
code: issue.code,
@@ -44,6 +85,48 @@ export function sanitiseZodIssues(
}));
}
/**
* v1.4.49 — diagnostic shape echoed into a wide-event meta when an iOS
* (or any other) caller fails Zod validation on a JSON payload. Pairs
* with the per-route `annotate({ action, meta: { ...payloadDiagnostic,
* zod_issues, issue_count } })` call. Each field is bounded:
*
* - `received_keys`: top-level keys only; never values
* - `received_shape_excerpt`: hard 256-char `JSON.stringify` slice
*
* Defensive shape: caller passes the raw parsed body (object | array |
* primitive | undefined). Non-object input collapses to an empty key
* list and an empty excerpt — the helper never throws.
*
* A redaction layer (PII / token-shape removal) is expected to be
* applied as a follow-up composable step by a parallel
* `W-OBSERV-PII-V1449` audit; this helper builds the unredacted shape
* so the redactor can post-process via a second helper call. Keeping
* the two concerns separate lets the unit test for this helper stay
* stable while the redaction policy evolves.
*/
export interface PayloadDiagnostic {
received_keys: string[];
received_shape_excerpt: string;
}
export function buildPayloadDiagnostic(body: unknown): PayloadDiagnostic {
const received_keys =
body && typeof body === "object" && !Array.isArray(body)
? Object.keys(body as Record<string, unknown>)
: [];
let excerpt: string;
try {
excerpt = JSON.stringify(body) ?? "";
} catch {
excerpt = "";
}
return {
received_keys,
received_shape_excerpt: excerpt.slice(0, 256),
};
}
type ErrorMeta = {
errorCode?: string;
headers?: Record<string, string>;
@@ -1,3 +1,6 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative, resolve } from "node:path";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
@@ -16,6 +19,9 @@ import { LayoutCoachFab } from "@/components/insights/layout-coach-fab";
import { LayoutCoachMount } from "@/components/insights/layout-coach-mount";
import { SuggestedPrompts } from "@/components/insights/suggested-prompts";
import { HeroStrip } from "@/components/insights/hero-strip";
import { TargetCard, type TargetCardData } from "@/components/targets/target-card";
import { useFeatureFlags } from "@/hooks/use-feature-flags";
import { useDisableCoach } from "@/hooks/use-disable-coach";
/**
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
@@ -171,8 +177,61 @@ const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
),
proofWhenVisible: 'data-slot="insights-hero-strip-action-coach"',
},
{
// v1.4.48 L2 — `<TargetCard>` carries the per-card "Ask the coach"
// CTA on the targets page. The card itself only consults
// `flags.coach`; the per-user disable gate is computed by the parent
// (`src/app/targets/page.tsx`) which does
// `aiEnabled = flags.coach && !user?.disableCoach && providerActive`.
// The driver below mirrors that wiring so the negative assertion
// (CTA absent when `disableCoach` is true) pins the chain end-to-end
// and trips a regression in either the card-level gate or the
// page-level computation.
name: "TargetCard per-card 'Ask the coach' CTA",
mount: () => <TargetCardDriver />,
proofWhenVisible: 'data-slot="target-coach-cta"',
},
];
/**
* Inline driver that mirrors the `src/app/targets/page.tsx` wiring of
* `aiEnabled` to the per-card CTA. Reads the same hooks the page does,
* threads the resolved boolean into `<TargetCard>`. Keeps the user-
* disable fixture honest about the cascade — a future contributor who
* "simplifies" the page's `coachEnabled` computation and drops the
* `user?.disableCoach` branch fails this surface's negative assertion.
*/
const TARGET_CARD_FIXTURE: TargetCardData = {
type: "WEIGHT",
label: "Weight",
current: 78.2,
average30: 79.1,
trend: "down",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
lastMetGoalAt: "2026-05-10",
streakDays: 4,
insufficientData: false,
consistency7d: ["in", "in", "near", "in", "out", "in", "in"],
};
function TargetCardDriver() {
const flags = useFeatureFlags();
const disableCoach = useDisableCoach();
const aiEnabled = flags.coach && !disableCoach;
return (
<TargetCard
target={TARGET_CARD_FIXTURE}
aiEnabled={aiEnabled}
onAskCoach={() => undefined}
/>
);
}
describe("Coach per-user disableCoach invariant", () => {
beforeEach(() => {
vi.spyOn(console, "error").mockImplementation(() => undefined);
@@ -236,4 +295,148 @@ describe("Coach per-user disableCoach invariant", () => {
).toHaveBeenCalled();
}
});
// ──────────────────────────────────────────────────────────────────
// v1.4.48 H2 — grep-based discovery of every `useDisableCoach` /
// `user.disableCoach` call site in the codebase.
//
// Mirrors the `flags.coach` discovery walk in `coach-cascade.test.tsx`:
// the surface fixture above pins the surfaces this file mounts
// directly. Cross-cut gates on sub-pages (`/targets` page parent
// computation, Settings → Insights toggle, `/api/auth/me` response
// shape, the disable-coach PATCH route) sit OUTSIDE the fixture and
// are owned by sibling invariants or by route-level tests.
//
// The walk below scans `src/` for every `useDisableCoach` /
// `user.disableCoach` / `user?.disableCoach` occurrence and requires
// each path to appear in the explicit `KNOWN_DISABLE_COACH_GATE_SITES`
// allowlist. Add a new entry every time you intentionally read the
// per-user disable flag in a new path; the failure message names the
// unaccounted-for paths so the fix is one search-and-add.
// ──────────────────────────────────────────────────────────────────
/**
* Every file in `src/` that legitimately reads `useDisableCoach` or
* `user.disableCoach`. Lives next to the disable-cascade fixture so a
* contributor extending the fixture sees the allowlist in the same
* review.
*
* Paths are POSIX-style relative to the repo root.
*/
const KNOWN_DISABLE_COACH_GATE_SITES: ReadonlyArray<string> = [
// Coach-bearing surfaces mounted directly by the disable-cascade
// fixture above; each one calls `useDisableCoach()` and short-
// circuits to `null` when the per-user flag is on.
"src/components/insights/coach-launch-button.tsx",
"src/components/insights/hero-strip.tsx",
"src/components/insights/layout-coach-fab.tsx",
"src/components/insights/layout-coach-mount.tsx",
"src/components/insights/suggested-prompts.tsx",
// The hook itself + its `useAuth`-backed reader.
"src/hooks/use-disable-coach.ts",
// Cross-cut gates owned by sibling invariants / route tests.
// - `/api/auth/me` ships the flag in the session payload so the
// client hooks can read it (`useAuth → useDisableCoach`). The
// sibling `/api/auth/me/disable-coach` PATCH endpoint that
// writes the flag does NOT read `user.disableCoach` (it reads
// `body.disableCoach`) and is intentionally not listed here.
"src/app/api/auth/me/route.ts",
// - The targets page's parent-level cascade: computes
// `coachEnabled = flags.coach && !user?.disableCoach` and threads
// `aiEnabled` down to `<TargetCard>`. The disable-cascade fixture
// covers the end-to-end chain via `<TargetCardDriver>`.
"src/app/targets/page.tsx",
// - Settings → Insights toggle reads + writes the flag.
"src/components/settings/ai-section.tsx",
];
/**
* Walk every TS / TSX file under `src/`, excluding test trees + the
* Prisma client output, and collect the relative paths that
* legitimately reference `useDisableCoach` or `user.disableCoach`.
* Any literal occurrence on a non-comment line counts so a
* contributor who paraphrases the read still trips this test.
*/
function findDisableCoachSites(): string[] {
const srcRoot = resolve(__dirname, "..", "..", "..");
const repoRoot = resolve(srcRoot, "..");
const hits: string[] = [];
function isRealGateLine(line: string): boolean {
// Trim once so leading whitespace doesn't fool the comment check.
const trimmed = line.trim();
if (trimmed.startsWith("//")) return false;
if (trimmed.startsWith("*")) return false;
// `useDisableCoach` covers both the hook import + the hook call.
if (line.includes("useDisableCoach")) return true;
// `user.disableCoach` / `user?.disableCoach` — the canonical
// per-user gate read on the auth user object. Strict match on
// `user` so we don't grab unrelated `disableCoach` strings (route
// body keys, i18n message ids, …).
if (/\buser\??\.disableCoach\b/.test(line)) return true;
return false;
}
function walk(dir: string): void {
const entries = readdirSync(dir);
for (const entry of entries) {
const full = join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "node_modules" || entry === ".next") continue;
if (entry === "__tests__") continue;
// Skip the Prisma client output — the generator embeds the
// entire `prisma/schema.prisma` source as a JSON-escaped
// `inlineSchema` string and any schema doc-comment that
// mentions `disableCoach` would otherwise leak through here.
if (entry === "generated") continue;
walk(full);
continue;
}
if (!/\.(tsx|ts)$/.test(entry)) continue;
if (/\.test\.(tsx|ts)$/.test(entry)) continue;
const text = readFileSync(full, "utf8");
if (text.split("\n").some(isRealGateLine)) {
hits.push(relative(repoRoot, full).split(/[\\/]/).join("/"));
}
}
}
walk(srcRoot);
return hits.sort();
}
it("grep-based discovery pins every `useDisableCoach` / `user.disableCoach` call site", () => {
const discovered = findDisableCoachSites();
const allowlist = new Set(KNOWN_DISABLE_COACH_GATE_SITES);
const orphans = discovered.filter((path) => !allowlist.has(path));
expect(
orphans,
[
"Found `useDisableCoach` / `user.disableCoach` in file(s) not on",
"the KNOWN_DISABLE_COACH_GATE_SITES allowlist",
"(src/lib/feature-flags/__tests__/coach-user-disable.test.tsx).",
"Add a fixture entry (or a sibling invariant) for the new gate,",
"then add the path to the allowlist. Orphans:",
...orphans.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
// Sanity check the other direction — an allowlist entry that no
// longer references the gate is dead documentation and should be
// pruned in the same PR that removes the read.
const discoveredSet = new Set(discovered);
const stale = KNOWN_DISABLE_COACH_GATE_SITES.filter(
(path) => !discoveredSet.has(path),
);
expect(
stale,
[
"KNOWN_DISABLE_COACH_GATE_SITES references file(s) that no longer",
"use `useDisableCoach` / `user.disableCoach`. Remove the stale",
"allowlist entry:",
...stale.map((p) => ` - ${p}`),
].join("\n"),
).toEqual([]);
});
});
@@ -219,7 +219,7 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", ()
});
// The whole point of the helper: counter is not in the update set.
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076); the per-kind bucket is the live counter now.
// (migration 0077); the per-kind bucket is the live counter now.
// Neither field appears in the update payload — the existing
// bucket values are preserved exactly.
expect(upsertArgs.update).not.toHaveProperty("consecutiveFailures");
@@ -279,7 +279,7 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", ()
// genuine transient burst still has the full 3-strike runway.
// v1.4.47 W1 — counter is now the per-kind bucket payload (the
// legacy `consecutiveFailures` column was dropped in migration
// 0076); a zero envelope is the equivalent seed.
// 0077); a zero envelope is the equivalent seed.
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(null);
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
{} as never,
@@ -98,7 +98,7 @@ describe("recordSyncSuccess", () => {
// v1.4.43 W14 — success clears every per-kind bucket so the
// next failure starts from zero in its own bucket.
// v1.4.47 W1 — the legacy `consecutiveFailures` column was
// dropped (migration 0076); the bucket reset is the only
// dropped (migration 0077); the bucket reset is the only
// counter write now.
consecutiveFailuresByKind: {
transient: 0,
@@ -482,7 +482,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
lastError: null,
// v1.4.43 W14 — disconnect must clear the per-kind buckets too.
// v1.4.47 W1 — legacy `consecutiveFailures` column dropped
// (migration 0076); the bucket reset is the only counter write.
// (migration 0077); the bucket reset is the only counter write.
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
@@ -505,7 +505,7 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
// v1.4.43 W14 — reconnect clears the per-kind buckets so the
// next sync starts on a clean slate.
// v1.4.47 W1 — legacy `consecutiveFailures` column dropped
// (migration 0076); the bucket reset is the only counter write.
// (migration 0077); the bucket reset is the only counter write.
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
@@ -687,7 +687,7 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
it("seeds a fresh zero envelope when the row has no bucket payload yet", async () => {
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076). A row that has never written a bucket payload
// (migration 0077). A row that has never written a bucket payload
// (`consecutiveFailuresByKind: null`) starts from a zero envelope
// on the next failure; the matching bucket ticks to 1 in a single
// write.
@@ -725,9 +725,9 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
});
});
// ── v1.4.47 W1 — migration 0076 down-script reversibility ──────────────
// ── v1.4.47 W1 — migration 0077 down-script reversibility ──────────────
//
// Migration 0076 drops `consecutive_failures` and documents a down-script
// Migration 0077 drops `consecutive_failures` and documents a down-script
// that restores the legacy column via
//
// SET "consecutive_failures" = COALESCE(
@@ -818,7 +818,7 @@ describe("resumeIntegrationFromPark", () => {
state: "connected",
lastError: null,
// v1.4.47 W1 — legacy `consecutiveFailures` column dropped
// (migration 0076); the bucket reset is the only counter write.
// (migration 0077); the bucket reset is the only counter write.
consecutiveFailuresByKind: {
transient: 0,
reauth_required: 0,
+4 -4
View File
@@ -250,7 +250,7 @@ export async function recordSyncSuccess(
// v1.4.43 W14 — a success resets ALL per-kind buckets back to zero
// and clears the persistent-streak start timestamp.
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076), so the bucket reset is the only counter write.
// (migration 0077), so the bucket reset is the only counter write.
await prisma.integrationStatus.upsert({
where: { userId_integration: { userId, integration } },
create: {
@@ -304,7 +304,7 @@ export interface RecordSyncFailureInput {
* ONLY its own bucket; a transient hiccup followed by a persistent
* failure no longer masks the persistent streak's true age.
* v1.4.47 W1 — the legacy single-column `consecutiveFailures` integer
* was dropped (migration 0076); the bucket is now the sole counter
* was dropped (migration 0077); the bucket is now the sole counter
* and the back-fill branch is gone.
*/
export async function recordSyncFailure(
@@ -448,7 +448,7 @@ export async function recordSyncFailure(
// a row with a 3-deep persistent streak still pages even when the
// transient bucket sat at 0.
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076); the bucket max is now the sole alert signal.
// (migration 0077); the bucket max is now the sole alert signal.
const threshold = getPersistentFailureThreshold();
const alertSignal = Math.max(...Object.values(buckets));
if (alertSignal >= threshold) {
@@ -635,7 +635,7 @@ export async function markReauthRequired(
message: string,
): Promise<void> {
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
// (migration 0076). Out-of-band reauth detection (proactive token
// (migration 0077). Out-of-band reauth detection (proactive token
// refresh) seeds the `reauth_required` bucket at 1 on a first-ever
// row so subsequent reauth detections accumulate against the same
// bucket the alert ladder reads.
@@ -0,0 +1,75 @@
/**
* Unit coverage for the v1.4.48 M1 worker-boot probe that flags
* legacy `integration_statuses` rows whose JSON failure bucket is
* still NULL.
*
* The contract under test:
* - emits a single `prisma.integrationStatus.count` against the
* `consecutiveFailuresByKind: { equals: null }` predicate;
* - when the count is > 0, the wrapping Wide Event is elevated to
* `warn` and the count is annotated under
* `meta.integration_status_null_buckets`;
* - when the count is 0, the event stays at `info` and no warning
* is added.
*/
import { describe, it, expect, vi } from "vitest";
import type { PrismaClient } from "@/generated/prisma/client";
import { WideEventBuilder } from "@/lib/logging/event-builder";
import { eventStorage } from "@/lib/logging/context";
import { probeIntegrationStatusNullBuckets } from "../integration-status-null-probe";
function makePrismaMock(count: number) {
return {
$queryRaw: vi.fn().mockResolvedValue([{ count: BigInt(count) }]),
} as unknown as PrismaClient;
}
async function runWithEvent<T>(
fn: (evt: WideEventBuilder) => Promise<T>,
): Promise<{ evt: WideEventBuilder; value: T }> {
const evt = new WideEventBuilder("background");
const value = await eventStorage.run(evt, () => fn(evt));
return { evt, value };
}
describe("probeIntegrationStatusNullBuckets", () => {
it("queries with the right predicate and returns the count", async () => {
const prisma = makePrismaMock(3);
const { value } = await runWithEvent(() =>
probeIntegrationStatusNullBuckets(prisma),
);
expect(value).toEqual({ count: 3 });
const queryRaw = prisma.$queryRaw as ReturnType<typeof vi.fn>;
expect(queryRaw).toHaveBeenCalledTimes(1);
const sql = (queryRaw.mock.calls[0][0] as TemplateStringsArray).join("");
expect(sql).toMatch(/consecutive_failures_by_kind\s+IS\s+NULL/i);
expect(sql).toMatch(/integration_statuses/);
});
it("elevates the event to warn and annotates the count when > 0", async () => {
const prisma = makePrismaMock(2);
const { evt } = await runWithEvent(() =>
probeIntegrationStatusNullBuckets(prisma),
);
evt.finish();
const json = evt.toJSON();
expect(json.level).toBe("warn");
expect(json.warnings?.[0]).toMatch(/2 row\(s\) still carry consecutiveFailuresByKind=NULL/);
expect(json.meta?.integration_status_null_buckets).toBe(2);
});
it("stays at info and emits no warning when count is 0", async () => {
const prisma = makePrismaMock(0);
const { evt } = await runWithEvent(() =>
probeIntegrationStatusNullBuckets(prisma),
);
evt.finish();
const json = evt.toJSON();
expect(json.level).toBe("info");
expect(json.warnings).toBeUndefined();
expect(json.meta?.integration_status_null_buckets).toBeUndefined();
});
});
@@ -0,0 +1,49 @@
/**
* v1.4.48 M1 — boot-time probe for legacy `IntegrationStatus` rows that
* still carry `consecutiveFailuresByKind: NULL`.
*
* v1.4.47 W1 dropped the single-column `consecutiveFailures` integer in
* favour of the per-kind JSON bucket. The alert-ladder reads
* `Math.max(...Object.values(buckets))` against the threshold — but a
* row that survives with the JSON column still NULL deterministically
* counts as `max([1]) = 1` on its first failure (the fall-back signal
* the writer emits when the row is absent), so the operator notices
* two strikes later than they did pre-v1.4.47 where the legacy column
* back-stopped the maths.
*
* Operator judgement ("every active row has been written under
* v1.4.43+, so the column is non-null") is not a code invariant. This
* probe is the runtime version of that invariant: one count query at
* worker boot, a single Wide-Event warning if any such row survives.
* Fire-and-forget — a probe failure must never block boot. Idempotent
* across reboots.
*/
import type { PrismaClient } from "@/generated/prisma/client";
import { annotate, getEvent } from "@/lib/logging/context";
interface IntegrationStatusNullProbeResult {
count: number;
}
type PrismaCountClient = Pick<PrismaClient, "$queryRaw">;
export async function probeIntegrationStatusNullBuckets(
prisma: PrismaCountClient,
): Promise<IntegrationStatusNullProbeResult> {
// Prisma's typed `count` rejects a literal `null` against a `Json?`
// column without going through `Prisma.JsonNullValueFilter`. The raw
// `IS NULL` is the simpler shape and matches the backlog spec.
const rows = await prisma.$queryRaw<Array<{ count: bigint }>>`
SELECT COUNT(*)::bigint AS count
FROM integration_statuses
WHERE consecutive_failures_by_kind IS NULL
`;
const count = Number(rows[0]?.count ?? 0);
if (count > 0) {
getEvent()?.addWarning(
`[integration-status-null-probe] ${count} row(s) still carry consecutiveFailuresByKind=NULL; alert ladder will trip two strikes late until they are rewritten`,
);
annotate({ meta: { integration_status_null_buckets: count } });
}
return { count };
}
+121
View File
@@ -98,6 +98,7 @@ import {
} from "@/lib/measurements/drain-per-sample-cumulative";
import { expireStaleInUseItems } from "@/lib/medications/inventory/service";
import { rotateLegacyMoodLogSecrets } from "@/lib/moodlog-secret";
import { probeIntegrationStatusNullBuckets } from "@/lib/jobs/integration-status-null-probe";
import { deleteMessage } from "@/lib/telegram";
import { decrypt, encrypt } from "@/lib/crypto";
import { syncMoodLogEntries } from "@/lib/moodlog/sync";
@@ -109,6 +110,7 @@ import {
getPhaseKeyboard,
} from "@/lib/jobs/reminder-phases";
import { runMoodReminderTick } from "@/lib/jobs/mood-reminder";
import { isMedicationReminderClientManaged } from "@/lib/validations/notification-prefs";
import { withBackgroundEvent } from "@/lib/logging/background";
import { assertSubsystemEnabled } from "@/lib/process-type";
import { runOffhostBackup } from "@/lib/jobs/offhost-backup";
@@ -232,6 +234,15 @@ const MOOD_REMINDER_CRON = "*/15 * * * *";
const MOOD_REMINDER_CLEANUP_QUEUE = "mood-reminder-cleanup";
const MOOD_REMINDER_CLEANUP_CRON = "25 3 * * *";
const MOOD_REMINDER_RETENTION_DAYS = 90;
// v1.4.49 — daily prune for the push-attempt ledger. Same 90-day
// retention as the mood-reminder dispatch ledger; both surfaces are
// behavioural footprints we keep long enough to debug a duplicate-push
// report (~one billing cycle) but no longer. Slots at 03:35 between
// mood-reminder cleanup (03:25) and drain-cumulative (03:45) so the
// 03:xx maintenance window stays ordered.
const PUSH_ATTEMPT_CLEANUP_QUEUE = "push-attempt-cleanup";
const PUSH_ATTEMPT_CLEANUP_CRON = "35 3 * * *";
const PUSH_ATTEMPT_RETENTION_DAYS = 90;
// v1.4.38 — the per-sample cutoff hours constant now lives on the
// helper module so the worker, the admin route, and the CLI all read
// the same source of truth. Re-export pulled in alongside
@@ -449,6 +460,12 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
// Used to localise the reminder title / message / keyboard
// labels per user. Null falls back to the app default.
locale: true,
// v1.4.49 M-DOUBLE-REMINDER — read the per-user prefs
// blob so the dispatch step can skip APNs sends for users
// whose iOS client has opted in to local SpeziScheduler
// reminders. Null = legacy default (clientManaged: false),
// i.e. the server reminder fires as before.
notificationPrefs: true,
},
},
},
@@ -613,6 +630,42 @@ async function handleReminderCheck(jobs: Job<ReminderCheckPayload>[]) {
// Send notification if enabled
if (med.notificationsEnabled) {
// v1.4.49 M-DOUBLE-REMINDER — opt-in client-managed
// suppression. When the iOS app has confirmed local
// SpeziScheduler banners cover the dose, the server-side
// APNs push is redundant. Skip the dispatch and emit a
// wide-event annotation so the operator can audit the
// skip path. ONLY suppresses MEDICATION_REMINDER — other
// notification kinds (MOOD_REMINDER, PERSONAL_RECORD,
// SYSTEM_ALERT, anomaly alerts) flow unchanged.
if (
isMedicationReminderClientManaged(med.user.notificationPrefs)
) {
const [winH, winM] = schedule.windowStart.split(":").map(Number);
const doseAtIso = localHmAsUtc(
now,
med.user.timezone,
winH,
winM,
).toISOString();
evt.addMeta(
"medication_reminder_suppressed_client_managed",
`${med.name}:${schedule.windowStart}-${schedule.windowEnd}`,
);
evt.addMeta(
"medication_reminder_suppressed_meta",
{
user_id: med.user.id,
medication_id: med.id,
schedule_id: schedule.id,
phase: currentPhase,
dose_at: doseAtIso,
},
);
schedulesProcessed++;
continue;
}
const { title, message } = getPhaseMessage(
currentPhase,
med.name,
@@ -1190,6 +1243,45 @@ async function handleMoodReminderCleanup(
});
}
/**
* v1.4.49 — daily prune for the per-attempt push-delivery ledger.
*
* Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) writes one
* fire-and-forget row to `push_attempts` per dispatch. The admin
* diagnostic endpoint only ever reads the trailing 20 rows per user,
* so anything older than the 90-day retention window is dead weight
* inflating the table and the `(user_id, created_at DESC)` index.
*
* The DELETE is unbounded by user — the index covers `created_at`
* directly, so a `WHERE created_at < cutoff` scan is bounded by the
* size of the trailing-edge of the table rather than the live working
* set. On a one-million-row table with the documented retention
* window, the daily prune touches ~11k rows (1M / 90d × 1d) and
* completes in milliseconds.
*/
interface PushAttemptCleanupPayload {
triggeredAt: string;
}
async function handlePushAttemptCleanup(
jobs: Job<PushAttemptCleanupPayload>[],
) {
void jobs;
await withBackgroundEvent("job.push_attempt_cleanup", async (evt) => {
const p = getWorkerPrisma();
try {
const cutoff = new Date();
cutoff.setUTCDate(cutoff.getUTCDate() - PUSH_ATTEMPT_RETENTION_DAYS);
const deleted = await p.pushAttempt.deleteMany({
where: { createdAt: { lt: cutoff } },
});
evt.addMeta("push_attempt_cleanup_deleted", deleted.count);
} catch (err) {
evt.addWarning(`push-attempt-cleanup failed: ${err}`);
}
});
}
async function handleMoodReminderCheck(jobs: Job<MoodReminderPayload>[]) {
void jobs;
await withBackgroundEvent("job.mood_reminder", async (evt) => {
@@ -1746,6 +1838,10 @@ export async function startReminderWorker() {
// dispatcher never fires.
MOOD_REMINDER_QUEUE,
MOOD_REMINDER_CLEANUP_QUEUE,
// v1.4.49 — push-attempt ledger cleanup. Same createQueue contract
// as the other cleanup jobs; the daily schedule below would
// silently no-op without this entry.
PUSH_ATTEMPT_CLEANUP_QUEUE,
];
for (const q of allQueues) {
@@ -1762,6 +1858,20 @@ export async function startReminderWorker() {
workerLog("error", "Failed to reconcile orphan ImportJob rows", err);
}
// v1.4.48 M1 — boot probe for legacy `integration_statuses` rows
// that still carry `consecutive_failures_by_kind = NULL`. After
// v1.4.47 dropped the single-column fallback, such rows alert two
// strikes later than they did pre-upgrade. The probe is a single
// count query + Wide-Event warning if any survive; fire-and-forget
// so a probe failure never blocks worker boot.
try {
await withBackgroundEvent("worker.boot.integration_status_null_probe", async () => {
await probeIntegrationStatusNullBuckets(getWorkerPrisma());
});
} catch (err) {
workerLog("error", "integration-status-null-probe failed", err);
}
// Schedule recurring cron jobs
const schedules: [string, string][] = [
[QUEUE_NAME, CHECK_INTERVAL_CRON],
@@ -1814,6 +1924,8 @@ export async function startReminderWorker() {
// per tick for the entire opted-in cohort.
[MOOD_REMINDER_QUEUE, MOOD_REMINDER_CRON],
[MOOD_REMINDER_CLEANUP_QUEUE, MOOD_REMINDER_CLEANUP_CRON],
// v1.4.49 — daily 03:35 Europe/Berlin prune for push_attempts.
[PUSH_ATTEMPT_CLEANUP_QUEUE, PUSH_ATTEMPT_CLEANUP_CRON],
];
for (const [name, cron] of schedules) {
@@ -1935,6 +2047,15 @@ export async function startReminderWorker() {
{ localConcurrency: 1 },
handleMoodReminderCleanup,
);
// v1.4.49 — daily prune of the push-attempt ledger. Single-flight
// matches every other cleanup queue; two ticks racing on the same
// DELETE statement is wasted work and the second tick's payload
// would be a no-op anyway.
await boss.work<PushAttemptCleanupPayload>(
PUSH_ATTEMPT_CLEANUP_QUEUE,
{ localConcurrency: 1 },
handlePushAttemptCleanup,
);
await boss.work<PrDetectionPayload>(
PR_DETECTION_QUEUE,
{ localConcurrency: PR_DETECTION_CONCURRENCY },
@@ -57,6 +57,13 @@ vi.mock("@/lib/db", () => ({
deleteMany: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
// v1.4.49 — sendViaApns now writes a fire-and-forget row to
// push_attempts on every exit path. The mock returns a resolved
// promise so the swallowed-error helper has nothing to swallow,
// keeping these legacy tests focused on the dispatcher contract.
pushAttempt: {
create: vi.fn().mockResolvedValue({}),
},
},
}));
@@ -0,0 +1,537 @@
/**
* v1.4.49 — fire-and-forget push-attempt ledger writes across the
* four senders (APNS, WEB_PUSH, TELEGRAM, NTFY).
*
* Pins:
* 1. Every sender calls `prisma.pushAttempt.create` with the
* documented row shape (userId / channel / eventType / result /
* reason) on its primary exit paths.
* 2. The write is fire-and-forget: a DB error from
* `pushAttempt.create` MUST NOT surface to the caller. The
* sender's `SendOutcome` carries on as if the ledger write had
* succeeded so the actual push delivery semantics stay intact.
* 3. The ledger writes are scoped to the right channel discriminator
* ("APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY") — no accidental
* cross-channel mislabeling under the shared helper.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// vi.mock factories run before module top-level executes, so anything
// referenced inside a factory must be created via vi.hoisted to share
// the same hoisted scope.
const hoisted = vi.hoisted(() => ({
apnsSendMock: vi.fn(),
apnsShutdownMock: vi.fn(),
webPushSetVapidDetailsMock: vi.fn(),
webPushSendNotificationMock: vi.fn(),
sendTelegramMessageMock: vi.fn(),
deleteTelegramMessageMock: vi.fn(),
}));
vi.mock("@parse/node-apn", () => {
class Provider {
send(...args: unknown[]) {
return hoisted.apnsSendMock(...args);
}
shutdown() {
return hoisted.apnsShutdownMock();
}
}
class Notification {
public topic = "";
public alert: unknown;
public sound: string | undefined;
public badge: number | undefined;
public threadId: string | undefined;
public collapseId: string | undefined;
public payload: unknown;
public category: string | undefined;
public mutableContent: boolean | undefined;
public interruptionLevel: string | undefined;
public priority: number | undefined;
}
return { default: { Provider, Notification }, Provider, Notification };
});
vi.mock("@/lib/db", () => ({
prisma: {
device: {
findMany: vi.fn(),
deleteMany: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
pushSubscription: {
findMany: vi.fn(),
deleteMany: vi.fn(),
},
pushAttempt: {
create: vi.fn(),
},
telegramReminderMessage: {
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn(),
upsert: vi.fn(),
},
},
}));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `enc(${s})`,
decrypt: (s: string) => s.replace(/^enc\(|\)$/g, ""),
}));
vi.mock("@/lib/logging/context", () => ({
getEvent: () => ({
addExternalCall: vi.fn(),
addWarning: vi.fn(),
setError: vi.fn(),
}),
}));
vi.mock("@/lib/notifications/vapid-config", () => ({
getVapidConfig: vi.fn().mockResolvedValue({
subject: "mailto:test@healthlog.local",
publicKey: "vapid-public-key",
privateKey: "vapid-private-key",
}),
}));
vi.mock("web-push", () => ({
default: {
setVapidDetails: hoisted.webPushSetVapidDetailsMock,
sendNotification: hoisted.webPushSendNotificationMock,
},
setVapidDetails: hoisted.webPushSetVapidDetailsMock,
sendNotification: hoisted.webPushSendNotificationMock,
}));
vi.mock("@/lib/telegram", () => ({
sendTelegramMessage: hoisted.sendTelegramMessageMock,
deleteMessage: hoisted.deleteTelegramMessageMock,
}));
import { sendViaApns, resetApnsForTesting } from "@/lib/notifications/senders/apns";
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
import { prisma } from "@/lib/db";
const TEST_EC_PEM_LINES = [
"-----BEGIN PRIVATE KEY-----",
"MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLOXP3Exjr5L5tamN",
"pTxck85Iaum80PdRlWDpc/ezviOgCgYIKoZIzj0DAQehRANCAAT1x8nKRb8KshQU",
"1aPieSCqOY6ilgC959umaFSlhfav8eZ91UHP/xond9aMoZcuQ7lJG/Rsj70SWMvZ",
"bw81BG89",
"-----END PRIVATE KEY-----",
];
const APNS_ENV = {
APNS_KEY_ID: "ABCDE12345",
APNS_TEAM_ID: "TEAM123456",
APNS_BUNDLE_ID: "test.healthlog.ios",
APNS_KEY: TEST_EC_PEM_LINES.join("\\n"),
};
function setApnsEnv(over: Record<string, string | undefined> = {}): void {
for (const k of [
"APNS_KEY_ID",
"APNS_TEAM_ID",
"APNS_BUNDLE_ID",
"APNS_KEY",
"APNS_KEY_B64",
"APNS_KEY_FILE",
"APNS_PRODUCTION",
]) {
delete process.env[k];
}
for (const [k, v] of Object.entries(over)) {
if (v !== undefined) process.env[k] = v;
}
}
// Capture every `create` call without forcing the test to a specific
// argument order. Each test asserts the call it cares about by
// filtering on `channel`.
function pushAttemptCalls(): Array<{
userId: string;
channel: string;
eventType: string;
result: string;
reason: string | null;
}> {
const create = vi.mocked(prisma.pushAttempt.create);
return create.mock.calls.map((c) => {
const args = c[0] as { data: Record<string, unknown> };
return args.data as never;
});
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(prisma.pushAttempt.create).mockResolvedValue({} as never);
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.device.deleteMany).mockResolvedValue({ count: 0 } as never);
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.pushSubscription.deleteMany).mockResolvedValue({
count: 0,
} as never);
resetApnsForTesting();
setApnsEnv(APNS_ENV);
});
afterEach(() => {
setApnsEnv();
resetApnsForTesting();
});
// Helper — the recordPushAttempt helper is fire-and-forget. Tests
// resolve a microtask tick to give the swallowed promise time to land
// before assertions.
async function flushMicrotasks(): Promise<void> {
await new Promise((resolve) => setImmediate(resolve));
}
describe("recordPushAttempt — APNS sender", () => {
it("writes one ok row when the dispatch succeeds", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
apnsToken: "a".repeat(64),
apnsEnvironment: "sandbox",
},
] as never);
hoisted.apnsSendMock.mockResolvedValue({
sent: [{ device: "a".repeat(64) }],
failed: [],
});
const outcome = await sendViaApns("user-1", {
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
});
await flushMicrotasks();
expect(outcome.ok).toBe(true);
const apns = pushAttemptCalls().filter((c) => c.channel === "APNS");
expect(apns).toHaveLength(1);
expect(apns[0]).toMatchObject({
userId: "user-1",
channel: "APNS",
eventType: "MEDICATION_REMINDER",
result: "ok",
reason: null,
});
});
it("writes one error row carrying Apple's reason on hard reject", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
apnsToken: "a".repeat(64),
apnsEnvironment: "sandbox",
},
] as never);
hoisted.apnsSendMock.mockResolvedValue({
sent: [],
failed: [{ response: { reason: "BadDeviceToken" }, status: "410" }],
});
await sendViaApns("user-2", {
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
});
await flushMicrotasks();
const apns = pushAttemptCalls().filter((c) => c.channel === "APNS");
expect(apns).toHaveLength(1);
expect(apns[0]).toMatchObject({
userId: "user-2",
channel: "APNS",
eventType: "MEDICATION_REMINDER",
result: "error",
reason: "BadDeviceToken",
});
});
it("writes a skipped row when no APNs devices are paired", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValue([] as never);
await sendViaApns("user-3", {
title: "T",
message: "M",
eventType: "MOOD_REMINDER",
});
await flushMicrotasks();
const apns = pushAttemptCalls().filter((c) => c.channel === "APNS");
expect(apns).toHaveLength(1);
expect(apns[0]).toMatchObject({
channel: "APNS",
result: "skipped",
reason: "apns_no_devices",
});
});
it("does NOT surface a ledger DB error to the caller", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValue([
{
id: "dev-1",
apnsToken: "a".repeat(64),
apnsEnvironment: "sandbox",
},
] as never);
hoisted.apnsSendMock.mockResolvedValue({
sent: [{ device: "a".repeat(64) }],
failed: [],
});
// Simulate a DB hiccup on the ledger write. The sender contract
// says this must not propagate.
vi.mocked(prisma.pushAttempt.create).mockRejectedValue(
new Error("connection refused"),
);
const outcome = await sendViaApns("user-4", {
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
});
await flushMicrotasks();
expect(outcome.ok).toBe(true);
});
});
describe("recordPushAttempt — WEB_PUSH sender", () => {
it("writes one ok row when at least one subscription accepts the push", async () => {
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([
{
id: "sub-1",
endpoint: "https://example.test/push",
p256dh: "enc(p)",
auth: "enc(a)",
},
] as never);
hoisted.webPushSendNotificationMock.mockResolvedValue(undefined);
const outcome = await sendViaWebPush("user-1", {
userId: "user-1",
title: "T",
message: "M",
eventType: "MEASUREMENT_ANOMALY",
});
await flushMicrotasks();
expect(outcome.ok).toBe(true);
const rows = pushAttemptCalls().filter((c) => c.channel === "WEB_PUSH");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
userId: "user-1",
channel: "WEB_PUSH",
eventType: "MEASUREMENT_ANOMALY",
result: "ok",
});
});
it("writes a skipped row when the user has zero subscriptions", async () => {
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([] as never);
await sendViaWebPush("user-2", {
userId: "user-2",
title: "T",
message: "M",
eventType: "SYSTEM_ALERT",
});
await flushMicrotasks();
const rows = pushAttemptCalls().filter((c) => c.channel === "WEB_PUSH");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
result: "skipped",
reason: "web_push_no_subscriptions",
});
});
it("does NOT surface a ledger DB error to the caller", async () => {
vi.mocked(prisma.pushSubscription.findMany).mockResolvedValue([
{
id: "sub-1",
endpoint: "https://example.test/push",
p256dh: "enc(p)",
auth: "enc(a)",
},
] as never);
hoisted.webPushSendNotificationMock.mockResolvedValue(undefined);
vi.mocked(prisma.pushAttempt.create).mockRejectedValue(
new Error("connection refused"),
);
const outcome = await sendViaWebPush("user-3", {
userId: "user-3",
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
});
await flushMicrotasks();
expect(outcome.ok).toBe(true);
});
});
describe("recordPushAttempt — TELEGRAM sender", () => {
it("writes one ok row carrying payload.userId when Telegram accepts the message", async () => {
hoisted.sendTelegramMessageMock.mockResolvedValue({
ok: true,
messageId: 42,
});
const outcome = await sendViaTelegram(
{ botToken: "bot:test", chatId: "chat-1" },
{
userId: "user-tg-1",
title: "T",
message: "M",
eventType: "MEDICATION_REMINDER",
},
);
await flushMicrotasks();
expect(outcome.ok).toBe(true);
const rows = pushAttemptCalls().filter((c) => c.channel === "TELEGRAM");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
userId: "user-tg-1",
channel: "TELEGRAM",
eventType: "MEDICATION_REMINDER",
result: "ok",
});
});
it("writes one error row with the classified reason on Telegram failure", async () => {
hoisted.sendTelegramMessageMock.mockResolvedValue({
ok: false,
errorDescription: "Forbidden: bot was blocked by the user",
});
await sendViaTelegram(
{ botToken: "bot:test", chatId: "chat-1" },
{
userId: "user-tg-2",
title: "T",
message: "M",
eventType: "MOOD_REMINDER",
},
);
await flushMicrotasks();
const rows = pushAttemptCalls().filter((c) => c.channel === "TELEGRAM");
expect(rows).toHaveLength(1);
expect(rows[0].result).toBe("error");
expect(rows[0].reason).toBeTruthy();
});
it("does NOT surface a ledger DB error to the caller", async () => {
hoisted.sendTelegramMessageMock.mockResolvedValue({
ok: true,
messageId: 99,
});
vi.mocked(prisma.pushAttempt.create).mockRejectedValue(
new Error("connection refused"),
);
const outcome = await sendViaTelegram(
{ botToken: "bot:test", chatId: "chat-1" },
{
userId: "user-tg-3",
title: "T",
message: "M",
eventType: "SYSTEM_ALERT",
},
);
await flushMicrotasks();
expect(outcome.ok).toBe(true);
});
});
describe("recordPushAttempt — NTFY sender", () => {
let fetchSpy: ReturnType<typeof vi.spyOn> | undefined;
beforeEach(() => {
fetchSpy = vi.spyOn(global, "fetch") as never;
});
afterEach(() => {
fetchSpy?.mockRestore();
});
it("writes one ok row when ntfy returns 200", async () => {
(fetchSpy as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response("", { status: 200 }),
);
const outcome = await sendViaNtfy(
{ serverUrl: "https://ntfy.test", topic: "hl" },
{
userId: "user-ntfy-1",
title: "T",
message: "M",
eventType: "PERSONAL_RECORD",
},
);
await flushMicrotasks();
expect(outcome.ok).toBe(true);
const rows = pushAttemptCalls().filter((c) => c.channel === "NTFY");
expect(rows).toHaveLength(1);
expect(rows[0]).toMatchObject({
userId: "user-ntfy-1",
channel: "NTFY",
eventType: "PERSONAL_RECORD",
result: "ok",
});
});
it("writes one error row when ntfy returns 5xx", async () => {
(fetchSpy as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response("", { status: 503 }),
);
await sendViaNtfy(
{ serverUrl: "https://ntfy.test", topic: "hl" },
{
userId: "user-ntfy-2",
title: "T",
message: "M",
eventType: "SYSTEM_ALERT",
},
);
await flushMicrotasks();
const rows = pushAttemptCalls().filter((c) => c.channel === "NTFY");
expect(rows).toHaveLength(1);
expect(rows[0].result).toBe("error");
});
it("does NOT surface a ledger DB error to the caller", async () => {
(fetchSpy as ReturnType<typeof vi.fn>).mockResolvedValue(
new Response("", { status: 200 }),
);
vi.mocked(prisma.pushAttempt.create).mockRejectedValue(
new Error("connection refused"),
);
const outcome = await sendViaNtfy(
{ serverUrl: "https://ntfy.test", topic: "hl" },
{
userId: "user-ntfy-3",
title: "T",
message: "M",
eventType: "PERSONAL_RECORD",
},
);
await flushMicrotasks();
expect(outcome.ok).toBe(true);
});
});
+39 -2
View File
@@ -29,6 +29,7 @@ import apn from "@parse/node-apn";
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
export interface ApnsPayload {
/** APNs `aps.alert.title` + `aps.alert.body`. */
@@ -526,6 +527,13 @@ export async function sendViaApns(
): Promise<SendOutcome> {
const config = loadApnsConfig();
if (!config) {
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "skipped",
reason: "apns_not_configured",
});
return {
ok: false,
hardReject: false,
@@ -548,6 +556,13 @@ export async function sendViaApns(
if (devices.length === 0) {
// Same rationale as web-push: a user who hasn't paired an iPhone yet
// shouldn't see the channel auto-disabled. Treat as soft "no recipient".
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "skipped",
reason: "apns_no_devices",
});
return {
ok: false,
hardReject: false,
@@ -667,25 +682,47 @@ export async function sendViaApns(
}
if (anySuccess) {
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "ok",
});
return { ok: true };
}
// Every surviving device returned a permanent reason → channel is dead
// until the user re-registers from a fresh iOS install.
if (deadDeviceIds.length === devices.length) {
const reason = lastFailureReason ?? "apns_all_devices_unregistered";
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "error",
reason,
});
return {
ok: false,
hardReject: true,
statusCode: lastFailureStatus,
reason: lastFailureReason ?? "apns_all_devices_unregistered",
reason,
};
}
const reason = lastFailureReason ?? "apns_send_failed";
recordPushAttempt({
userId,
channel: "APNS",
eventType: payload.eventType,
result: "error",
reason,
});
return {
ok: false,
hardReject: false,
statusCode: lastFailureStatus,
reason: lastFailureReason ?? "apns_send_failed",
reason,
};
}
+21
View File
@@ -5,6 +5,7 @@ import type {
import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
/**
* Send notification via ntfy (simple HTTP POST).
@@ -52,9 +53,22 @@ export async function sendViaNtfy(
});
if (res.ok) {
recordPushAttempt({
userId: payload.userId,
channel: "NTFY",
eventType: payload.eventType,
result: "ok",
});
return { ok: true, statusCode: res.status };
}
const classified = classifyHttpStatus(res.status, "ntfy");
recordPushAttempt({
userId: payload.userId,
channel: "NTFY",
eventType: payload.eventType,
result: "error",
reason: classified.reason,
});
return {
ok: false,
statusCode: res.status,
@@ -69,6 +83,13 @@ export async function sendViaNtfy(
duration_ms: Math.round(performance.now() - start),
error: message,
});
recordPushAttempt({
userId: payload.userId,
channel: "NTFY",
eventType: payload.eventType,
result: "error",
reason: "ntfy_network_error",
});
return {
ok: false,
hardReject: false,
@@ -0,0 +1,62 @@
import { prisma } from "@/lib/db";
import { getEvent } from "@/lib/logging/context";
/**
* v1.4.49 — fire-and-forget push-attempt ledger write.
*
* Every sender (APNS, WEB_PUSH, TELEGRAM, NTFY) calls this helper
* once per dispatch so the admin diagnostic endpoint
* (`/api/admin/notifications/diagnostic`) can surface the trailing
* 20 attempts per user without DB shell access.
*
* Contract:
* * NEVER throws. A DB hiccup on the insert must not break the
* actual push delivery, so we swallow the error and emit a
* wide-event warning the operator can correlate with the
* primary sender's outcome.
* * Single insert per call. No retry, no batching — a missing row
* is a strictly worse outcome than a duplicate one, but we
* accept it rather than amplify a DB blip into a retry storm.
* * Synchronous return is `void`. Callers must use
* `void recordPushAttempt(...)` so the sender's hot path doesn't
* await the ledger write. The pg connection pool is shared with
* the dispatcher's read queries; awaiting here would block the
* next channel in the cascade for no operational benefit.
*
* Result discriminator:
* * `"ok"` — the sender returned a successful `SendOutcome`.
* * `"error"` — the sender returned `ok: false` with a
* classified failure (hard or soft reject).
* * `"skipped"` — soft "no recipient" / "config missing" cases
* the sender contracts surface without contacting
* the upstream provider.
*/
export interface PushAttemptRecord {
userId: string;
channel: "APNS" | "WEB_PUSH" | "TELEGRAM" | "NTFY";
eventType: string;
result: "ok" | "error" | "skipped";
reason?: string | null;
}
export function recordPushAttempt(record: PushAttemptRecord): void {
// The `void` here is the doubly-explicit form: we want neither the
// caller nor a linter to wait on this promise. The `.catch` traps
// a rejected promise without surfacing it to the caller.
void prisma.pushAttempt
.create({
data: {
userId: record.userId,
channel: record.channel,
eventType: record.eventType,
result: record.result,
reason: record.reason ?? null,
},
})
.catch((err: unknown) => {
const message = err instanceof Error ? err.message : "unknown_error";
getEvent()?.addWarning(
`push_attempt_ledger_write_failed channel=${record.channel} reason=${message}`,
);
});
}
+14
View File
@@ -8,6 +8,7 @@ import { classifyTelegramError } from "@/lib/notifications/retry-policy";
import { prisma } from "@/lib/db";
import type { ReminderPhase } from "@/generated/prisma/client";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
/**
* Telegram sender result. Inherits from `SendOutcome` so the dispatcher
@@ -154,9 +155,22 @@ export async function sendViaTelegram(
}
if (result.ok) {
recordPushAttempt({
userId: payload.userId,
channel: "TELEGRAM",
eventType: payload.eventType,
result: "ok",
});
return { ok: true, messageId: result.messageId };
}
const classified = classifyTelegramError(result.errorDescription);
recordPushAttempt({
userId: payload.userId,
channel: "TELEGRAM",
eventType: payload.eventType,
result: "error",
reason: classified.reason,
});
return {
ok: false,
hardReject: classified.hardReject,
+42
View File
@@ -5,6 +5,7 @@ import type { SendOutcome } from "@/lib/notifications/retry-policy";
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
import { getVapidConfig } from "@/lib/notifications/vapid-config";
import { getEvent } from "@/lib/logging/context";
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
/**
* Send Web Push notification to all subscribed devices of a user.
@@ -32,6 +33,13 @@ export async function sendViaWebPush(
const config = await getVapidConfig();
if (!config) {
getEvent()?.addWarning("Web Push: VAPID keys not configured");
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "skipped",
reason: "web_push_vapid_not_configured",
});
return {
ok: false,
hardReject: false,
@@ -54,6 +62,13 @@ export async function sendViaWebPush(
// may simply not have hit "Subscribe" on this browser. Counting
// this as a give-up signal would lock the user out of the channel
// they just configured. Treat it as a soft "no recipient" instead.
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "skipped",
reason: "web_push_no_subscriptions",
});
return {
ok: false,
hardReject: false,
@@ -119,12 +134,25 @@ export async function sendViaWebPush(
}
if (anySuccess) {
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "ok",
});
return { ok: true };
}
// Every sub returned a permanent 410/404 → channel is dead until
// the user re-pairs a device. This is a hard reject.
if (allPermanentReject && expiredIds.length === subscriptions.length) {
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "error",
reason: "web_push_410_gone",
});
return {
ok: false,
hardReject: true,
@@ -135,6 +163,13 @@ export async function sendViaWebPush(
// Soft failure (5xx / 429 / fetch error) on at least one sub.
const classified = classifyHttpStatus(lastTransientStatus, "web-push");
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "error",
reason: classified.reason,
});
return {
ok: false,
hardReject: false,
@@ -147,6 +182,13 @@ export async function sendViaWebPush(
? err
: new Error("[web-push] sendViaWebPush failed"),
);
recordPushAttempt({
userId,
channel: "WEB_PUSH",
eventType: payload.eventType,
result: "error",
reason: "web_push_unexpected_error",
});
return {
ok: false,
hardReject: false,
@@ -0,0 +1,130 @@
import { describe, it, expect } from "vitest";
import {
redactSensitiveFields,
SENSITIVE_KEY_PATTERNS,
} from "../redact-payload";
// v1.4.49 — pinned redactor for the wide-event `received_shape_excerpt`
// surface introduced in v1.4.48 H-iOS-1 / H-iOS-2. The helper must
// strip values for any key matching the SENSITIVE_KEY_PATTERNS denylist
// before the caller's `JSON.stringify` lands in the meta excerpt.
describe("redactSensitiveFields", () => {
it("passes a body with no sensitive keys through unchanged", () => {
const body = {
version: 1,
widgets: [{ id: "weight", visible: true, order: 0 }],
comparisonBaseline: "lastMonth",
};
const result = redactSensitiveFields(body);
expect(result).toEqual(body);
// Returns a clone, not the original reference.
expect(result).not.toBe(body);
});
it("redacts a top-level `password` key", () => {
const body = { username: "alice", password: "hunter2" };
expect(redactSensitiveFields(body)).toEqual({
username: "alice",
password: "[redacted]",
});
});
it("redacts `Authorization` regardless of case (Bearer prefix preserved as redacted)", () => {
const body = { Authorization: "Bearer eyJhbGciOiJIUzI1NiJ9.xxx" };
expect(redactSensitiveFields(body)).toEqual({
Authorization: "[redacted]",
});
});
it("redacts a nested object's sensitive fields recursively", () => {
const body = {
kind: "device.register",
payload: {
deviceId: "abc-123",
apnsToken: "ff".repeat(32),
meta: { secret: "shh", model: "iPhone15" },
},
};
expect(redactSensitiveFields(body)).toEqual({
kind: "device.register",
payload: {
deviceId: "abc-123",
apnsToken: "[redacted]",
meta: { secret: "[redacted]", model: "iPhone15" },
},
});
});
it("redacts inside array members", () => {
const body = {
entries: [
{ id: "1", apiKey: "sk_live_xxx" },
{ id: "2", token: "bearer-xyz" },
{ id: "3", note: "free text" },
],
};
expect(redactSensitiveFields(body)).toEqual({
entries: [
{ id: "1", apiKey: "[redacted]" },
{ id: "2", token: "[redacted]" },
{ id: "3", note: "free text" },
],
});
});
it("matches the token denylist patterns case-insensitively (apnsToken, bearerToken, csrf_state)", () => {
const body = {
apnsToken: "ff".repeat(32),
bearerToken: "abc",
csrf_state: "xyz",
csrfState: "xyz2",
api_key: "sk_xxx",
Nonce: "01234",
randomField: "kept",
};
expect(redactSensitiveFields(body)).toEqual({
apnsToken: "[redacted]",
bearerToken: "[redacted]",
csrf_state: "[redacted]",
csrfState: "[redacted]",
api_key: "[redacted]",
Nonce: "[redacted]",
randomField: "kept",
});
});
it("passes scalars + null + undefined through unchanged", () => {
expect(redactSensitiveFields(null)).toBeNull();
expect(redactSensitiveFields(undefined)).toBeUndefined();
expect(redactSensitiveFields("string")).toBe("string");
expect(redactSensitiveFields(42)).toBe(42);
expect(redactSensitiveFields(true)).toBe(true);
});
it("does not recurse into non-plain objects (Date stays intact)", () => {
const date = new Date("2026-05-22T10:00:00Z");
const body = { measuredAt: date, password: "x" };
const result = redactSensitiveFields(body) as Record<string, unknown>;
expect(result.measuredAt).toBe(date);
expect(result.password).toBe("[redacted]");
});
it("exposes the SENSITIVE_KEY_PATTERNS denylist as a readonly tuple", () => {
// Pin the public surface so a future commit can't silently drop
// one of the documented patterns.
const required = [
"password",
"token",
"secret",
"apikey",
"authorization",
"csrfstate",
"nonce",
];
for (const key of required) {
const matched = SENSITIVE_KEY_PATTERNS.some((re) => re.test(key));
expect(matched, `pattern set should match ${key}`).toBe(true);
}
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* v1.4.49 — sensitive-field redactor for observability payloads.
*
* The v1.4.48 H-iOS-1 / H-iOS-2 work surfaces a 256-char truncated
* `JSON.stringify` of the request body to the wide-event
* `meta.received_shape_excerpt`. Today's iOS surface (widget layout +
* series query) only carries enum / boolean / int fields so a verbatim
* excerpt is safe, but the pattern is heading for routes that accept
* tokens (APNs registration, Withings re-auth, API tokens) and bearer
* credentials. Pre-empt the leak: every body that flows into a
* wide-event excerpt is first walked through `redactSensitiveFields`,
* which replaces any key matching one of the `SENSITIVE_KEY_PATTERNS`
* regexes with the literal string `"[redacted]"`.
*
* Adding a new sensitive token is a one-line append to
* `SENSITIVE_KEY_PATTERNS`. The match is case-insensitive and runs
* against the literal key name, so `apnsToken`, `bearerToken`,
* `APIKey`, `Authorization` and `csrf_state` all redact without
* extra spellings.
*
* The redactor recurses into nested objects + arrays so deeply-nested
* credentials (e.g. `{ creds: { password: "…" } }`) are caught the
* same way. Non-object scalar inputs pass through unchanged — the
* caller's `JSON.stringify` then truncates as before.
*/
export const SENSITIVE_KEY_PATTERNS: readonly RegExp[] = [
/password/i,
/token/i,
/secret/i,
/apikey/i,
/api[_-]key/i,
/authorization/i,
/csrfstate/i,
/csrf[_-]state/i,
/nonce/i,
];
const REDACTED = "[redacted]";
function keyIsSensitive(key: string): boolean {
for (const re of SENSITIVE_KEY_PATTERNS) {
if (re.test(key)) return true;
}
return false;
}
/**
* Returns a deep clone of `body` with any key matching the sensitive
* denylist replaced by the literal `"[redacted]"`. Arrays + plain
* objects are recursed into; scalars, `null`, `undefined`, `Date` and
* other non-plain objects pass through unchanged.
*
* The clone is intentionally shallow on non-plain-object boundaries so
* the helper stays cheap on the hot 422 path; the only goal is to keep
* the credentialed key's value out of the eventual JSON excerpt.
*/
export function redactSensitiveFields(body: unknown): unknown {
if (body === null || body === undefined) return body;
if (Array.isArray(body)) {
return body.map((entry) => redactSensitiveFields(entry));
}
if (typeof body !== "object") return body;
// Skip non-plain objects (Date, Map, Set, Buffer, ...) — they'll
// serialise to a benign form via JSON.stringify and we don't want to
// walk into framework internals.
const proto = Object.getPrototypeOf(body);
if (proto !== Object.prototype && proto !== null) return body;
const out: Record<string, unknown> = {};
for (const [key, value] of Object.entries(body as Record<string, unknown>)) {
if (keyIsSensitive(key)) {
out[key] = REDACTED;
continue;
}
out[key] = redactSensitiveFields(value);
}
return out;
}
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// Stub the launcher import so the helper test never has to instantiate
// the @dnd-kit / launcher render tree just to exercise its
// sessionStorage write side-effect. We assert the helper *calls* it on
// the success branch and skips it when no user-id was passed in.
const setTourForceLaunchSpy = vi.fn();
vi.mock("@/components/onboarding/tour-launcher", () => ({
setTourForceLaunch: (userId: string) => setTourForceLaunchSpy(userId),
}));
import { restartOnboardingTour } from "../tour-restart";
describe("restartOnboardingTour()", () => {
beforeEach(() => {
setTourForceLaunchSpy.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("success branch — flips the server flag, fires the force-launch marker, returns { ok: true }", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response("{}", { status: 200 }));
const result = await restartOnboardingTour("user-42", { fetchImpl });
expect(result).toEqual({ ok: true });
expect(fetchImpl).toHaveBeenCalledTimes(1);
const [url, init] = fetchImpl.mock.calls[0];
expect(url).toBe("/api/onboarding/tour");
expect(init?.method).toBe("POST");
expect(init?.headers).toEqual({ "Content-Type": "application/json" });
expect(init?.body).toBe(JSON.stringify({ completed: false }));
// Per-user force-launch marker fired on success.
expect(setTourForceLaunchSpy).toHaveBeenCalledWith("user-42");
});
it("success branch — skips the force-launch marker when no userId was supplied", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response("{}", { status: 200 }));
const result = await restartOnboardingTour(undefined, { fetchImpl });
expect(result).toEqual({ ok: true });
expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
});
it("error-network branch — fetch rejects, returns { ok: false } with common.networkError", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockRejectedValue(new TypeError("network down"));
const result = await restartOnboardingTour("user-42", { fetchImpl });
expect(result).toEqual({ ok: false, messageKey: "common.networkError" });
// No force-launch marker should fire on the error branch — a failed
// server flip MUST NOT arm the launcher.
expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
});
it("error-server-422 branch — non-ok response returns { ok: false } with settings.savingError", async () => {
const fetchImpl = vi.fn<typeof fetch>().mockResolvedValue(
new Response(JSON.stringify({ error: "Unprocessable" }), {
status: 422,
}),
);
const result = await restartOnboardingTour("user-42", { fetchImpl });
expect(result).toEqual({ ok: false, messageKey: "settings.savingError" });
expect(setTourForceLaunchSpy).not.toHaveBeenCalled();
});
});
+83
View File
@@ -0,0 +1,83 @@
/**
* v1.4.48 M6b — shared "restart onboarding tour" worker.
*
* `<AccountSection>` and `<AboutSection>` both expose a button that
* resets `users.onboarding_tour_completed = false` and arranges for the
* dashboard's `<TourLauncher>` to re-open the spotlight on the user's
* next navigation. Until v1.4.48 the two handlers were 90 % identical
* copy-paste — a known drift hazard. This helper owns the surface so
* both call sites narrow to a single mutate-then-route step:
*
* const result = await restartOnboardingTour(user?.id);
* if (result.ok) toast(t("onboarding.tour.restartConfirmation"));
* else toast(t(result.messageKey));
*
* Side-effects this helper owns:
*
* - PUT `/api/onboarding/tour` with `{ completed: false }` (the same
* server flip both Account and About already drove).
* - On success — write the per-user `setTourForceLaunch(userId)`
* marker into sessionStorage so the next dashboard mount opens
* the tour even when the 24 h auto-launch gate would suppress it.
* - Fire a `healthlog:tour-restart` window event so a dashboard
* mounted in another tab / background reopens the spotlight
* immediately. The dispatch is best-effort; SSR or sandboxed
* iframes that can't construct CustomEvent are tolerated.
*
* Translation + toast rendering are deliberately NOT owned here — the
* helper returns a discriminated `{ ok }` result and lets the React
* component pick the right `useTranslations()` instance. Keeps the
* helper pure of i18n state and easy to unit-test under the default
* vitest "node" environment.
*/
import { setTourForceLaunch } from "@/components/onboarding/tour-launcher";
export type RestartTourResult =
| { ok: true }
| { ok: false; messageKey: string };
export interface RestartTourOptions {
/**
* Fetch implementation injected for tests. Defaults to the global
* `fetch` so production code paths read exactly as before.
*/
fetchImpl?: typeof fetch;
}
/**
* Persist the tour-replay request server-side and arm the launcher
* for the user's next dashboard navigation.
*
* `userId` may be undefined when the auth payload hasn't landed yet —
* the server flip still fires, and the force-launch marker is simply
* skipped (the launcher's secondary "tour-completed = false" path
* still picks the change up on the next `/api/auth/me` read).
*/
export async function restartOnboardingTour(
userId: string | undefined,
opts: RestartTourOptions = {},
): Promise<RestartTourResult> {
const fetchImpl = opts.fetchImpl ?? fetch;
try {
const res = await fetchImpl("/api/onboarding/tour", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ completed: false }),
});
if (!res.ok) {
return { ok: false, messageKey: "settings.savingError" };
}
if (userId) setTourForceLaunch(userId);
try {
if (typeof window !== "undefined") {
window.dispatchEvent(new CustomEvent("healthlog:tour-restart"));
}
} catch {
/* ignore — only matters if a dashboard is already mounted */
}
return { ok: true };
} catch {
return { ok: false, messageKey: "common.networkError" };
}
}
+150
View File
@@ -123,6 +123,24 @@ const coachMessageFeedbackBody = z
"Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.",
});
// v1.4.49 — single schema for the per-user Coach opt-out flag. Previously
// split into `disableCoachBody` (PATCH request) and `disableCoachData`
// (response payload), both `z.object({ disableCoach: z.boolean() })`. The
// payload was passed through `dataEnvelope(..., "GetDisableCoachResponse")`
// / `dataEnvelope(..., "PatchDisableCoachResponse")` which IDs the
// envelope wrapper — the inner flag carries its own `.meta()` and now
// renders as a single `$ref: "#/components/schemas/DisableCoachFlag"` in
// both the request body and both response envelopes.
const disableCoachFlag = z
.object({
disableCoach: z.boolean(),
})
.meta({
id: "DisableCoachFlag",
description:
"Per-account Coach opt-out toggle (v1.4.47 W3). `true` hides the Coach FAB and short-circuits its API gates.",
});
// ── Sub-schemas owned here (route-specific shapes) ───────────────────
const passkeyLoginVerifyRequest = z
@@ -408,6 +426,68 @@ const measurementResource = z
description: "Server-shaped measurement row returned by GET endpoints.",
});
// v1.4.48 H-APNs-1 — admin diagnostic endpoint for the notification
// subsystem. Mirrors the runtime types in
// `src/app/api/admin/notifications/diagnostic/route.ts`: APNs tokens
// are surfaced only as 8-char hex prefix + suffix (never the full
// token), per-channel `enabled` + `configPresent` booleans, and a
// `recentPushAttempts` array reserved for the v1.4.48+ follow-up that
// adds the dedicated PushAttempt table (currently always `[]` so the
// shape stays stable for iOS consumers).
const adminDiagnosticDevice = z
.object({
id: z.string(),
platform: z.string(),
hasApnsToken: z.boolean(),
apnsTokenPrefix: z.string().nullable(),
apnsTokenSuffix: z.string().nullable(),
apnsEnvironment: z.string().nullable(),
lastSeenAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "AdminDiagnosticDevice",
description:
"Per-device APNs registration snapshot. The APNs token is masked to its 8-char hex prefix + 8-char suffix so an operator can correlate with iOS-side logs without disclosing the delivery target.",
});
const adminDiagnosticChannel = z
.object({
type: z.string(),
enabled: z.boolean(),
configPresent: z.boolean(),
})
.meta({
id: "AdminDiagnosticChannel",
description:
"Per-channel state. `configPresent` is true when the channel's encrypted-JSON config blob carries the field the corresponding sender will read (chatId+botToken for TELEGRAM, topic for NTFY); WEB_PUSH/APNS rely on sibling tables so the row's existence is the signal.",
});
const adminDiagnosticPushAttempt = z
.object({
eventType: z.string(),
channel: z.string(),
result: z.string(),
reason: z.string().nullable(),
at: z.iso.datetime({ offset: true }),
})
.meta({
id: "AdminDiagnosticPushAttempt",
description:
"Single push delivery attempt. Reserved for the v1.4.48+ follow-up that adds the dedicated PushAttempt table — currently always returned as an empty array so the response shape stays stable for iOS consumers.",
});
const adminDiagnosticData = z
.object({
devices: z.array(adminDiagnosticDevice),
notificationChannels: z.array(adminDiagnosticChannel),
recentPushAttempts: z.array(adminDiagnosticPushAttempt),
})
.meta({
id: "AdminDiagnosticData",
description:
"Admin notification diagnostic snapshot for the calling user — what the dispatcher would see when targeting this account. Surfaces device tokens (masked), channel state, and recent push attempts so an operator can debug an iOS / Web Push / Telegram / ntfy issue without DB shell access.",
});
const insightsComprehensiveResponse = z
.object({
summary: z.string(),
@@ -780,6 +860,50 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/auth/me/disable-coach": {
get: {
tags: ["Auth"],
summary: "Read per-user Coach opt-out flag",
description:
"Returns the user's per-account Coach opt-out flag. Default `false` (Coach visible). Powers the Settings → Insights \"Hide Coach\" Switch and the layout FAB short-circuit.",
responses: {
"200": {
description: "Resolved flag.",
content: {
"application/json": {
schema: dataEnvelope(disableCoachFlag, "GetDisableCoachResponse"),
},
},
},
...stdResponses,
},
},
patch: {
tags: ["Auth"],
summary: "Toggle per-user Coach opt-out flag",
description:
"Flips the per-account Coach opt-out flag. Idempotent — the DB write fires even when the value matches so the audit-log row mirrors the API call. Rate-limit 60/min per user.",
requestBody: {
required: true,
content: { "application/json": { schema: disableCoachFlag } },
},
responses: {
"200": {
description:
"Resolved next-state echoed back for optimistic-update consumers.",
content: {
"application/json": {
schema: dataEnvelope(
disableCoachFlag,
"PatchDisableCoachResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/auth/me/source-priority": {
get: {
tags: ["Auth"],
@@ -865,6 +989,32 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/admin/notifications/diagnostic": {
get: {
tags: ["Admin"],
summary: "Admin notification diagnostic snapshot",
description:
"Admin-only. Returns what the dispatcher would see when targeting the calling admin's account: registered devices (APNs tokens masked to prefix + suffix), per-channel enabled + configPresent flags, and recent push attempts (currently always `[]` pending the v1.4.48+ PushAttempt table). Cookie auth only — never Bearer.",
responses: {
"200": {
description: "Diagnostic snapshot.",
content: {
"application/json": {
schema: dataEnvelope(
adminDiagnosticData,
"AdminDiagnosticResponse",
),
},
},
},
"403": {
description: "Caller is not an admin.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/insights/comprehensive": {
get: {
tags: ["Insights"],
@@ -0,0 +1,146 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_NOTIFICATION_PREFS,
isMedicationReminderClientManaged,
notificationPrefsSchema,
parseNotificationPrefs,
resolveNotificationPrefs,
} from "../notification-prefs";
/**
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
*
* The cron at `src/lib/jobs/reminder-worker.ts` reads
* `user.notificationPrefs` and calls
* `isMedicationReminderClientManaged` to decide whether to skip the
* MEDICATION_REMINDER APNs dispatch. These tests pin the helper's
* contract so the cron skip is deterministic across schema drift.
*/
describe("notificationPrefsSchema", () => {
it("accepts an empty object (every key is optional)", () => {
const out = notificationPrefsSchema.parse({});
expect(out).toEqual({});
});
it("accepts the medication.clientManaged shape", () => {
const input = { medication: { clientManaged: true } };
const out = notificationPrefsSchema.parse(input);
expect(out).toEqual(input);
});
it("rejects a non-boolean clientManaged", () => {
const res = notificationPrefsSchema.safeParse({
medication: { clientManaged: "yes" },
});
expect(res.success).toBe(false);
});
});
describe("parseNotificationPrefs", () => {
it("returns documented defaults for null", () => {
expect(parseNotificationPrefs(null)).toEqual(DEFAULT_NOTIFICATION_PREFS);
});
it("returns documented defaults for undefined", () => {
expect(parseNotificationPrefs(undefined)).toEqual(
DEFAULT_NOTIFICATION_PREFS,
);
});
it("returns defaults when the persisted shape has drifted", () => {
expect(parseNotificationPrefs({ unknown: "shape" })).toEqual(
DEFAULT_NOTIFICATION_PREFS,
);
});
it("returns the persisted shape when valid", () => {
expect(
parseNotificationPrefs({ medication: { clientManaged: true } }),
).toEqual({ medication: { clientManaged: true } });
});
it("fills missing keys from the defaults", () => {
expect(parseNotificationPrefs({ medication: {} })).toEqual({
medication: { clientManaged: false },
});
});
});
describe("resolveNotificationPrefs (deep-merge)", () => {
it("layers the input over the persisted row", () => {
const out = resolveNotificationPrefs(
{ medication: { clientManaged: false } },
{ medication: { clientManaged: true } },
);
expect(out).toEqual({ medication: { clientManaged: true } });
});
it("preserves the persisted medication keys when the input only touches new sub-keys", () => {
// Future-proof: the route hands the merged shape to Prisma, so
// sibling keys inside `medication` must survive a partial PATCH.
const out = resolveNotificationPrefs(
{ medication: { clientManaged: true } },
{ medication: {} },
);
expect(out).toEqual({ medication: { clientManaged: true } });
});
it("falls back to defaults when the persisted row is null", () => {
const out = resolveNotificationPrefs(null, {
medication: { clientManaged: true },
});
expect(out).toEqual({ medication: { clientManaged: true } });
});
it("returns the defaults when neither side carries a value", () => {
const out = resolveNotificationPrefs(null, {});
expect(out).toEqual(DEFAULT_NOTIFICATION_PREFS);
});
});
describe("isMedicationReminderClientManaged — cron-skip gate", () => {
it("returns false for a null row (legacy server-managed default)", () => {
expect(isMedicationReminderClientManaged(null)).toBe(false);
});
it("returns false for undefined (Prisma findUnique miss)", () => {
expect(isMedicationReminderClientManaged(undefined)).toBe(false);
});
it("returns false when the user has not opted in", () => {
expect(
isMedicationReminderClientManaged({
medication: { clientManaged: false },
}),
).toBe(false);
});
it("returns true when the user has opted in via the iOS app", () => {
expect(
isMedicationReminderClientManaged({
medication: { clientManaged: true },
}),
).toBe(true);
});
it("returns false for a drifted persisted shape (forward-compat)", () => {
expect(
isMedicationReminderClientManaged({ notACategory: { foo: "bar" } }),
).toBe(false);
});
it("returns false when only sibling categories are present", () => {
// Future-proof: another (future) category set to true must NOT
// gate MEDICATION_REMINDER — only `medication.clientManaged`
// affects the medication cron.
expect(
isMedicationReminderClientManaged({
// Cast through unknown — the production runtime sees this as
// a JSONB value that bypasses the zod schema, but the helper
// must still resolve to the safe default.
otherCategory: { enabled: true },
} as unknown),
).toBe(false);
});
});
+128
View File
@@ -0,0 +1,128 @@
/**
* v1.4.49 M-DOUBLE-REMINDER — per-user notification preferences blob.
*
* Persisted on `User.notificationPrefs` as Postgres jsonb. Null = the
* documented defaults (every category off). The flag is opt-in: the
* iOS app flips it to `true` only after confirming local Spezi
* reminders are wired, so existing users without a working local
* notification path see zero regression.
*
* Shape (additive — future categories slot in next to `medication`):
* {
* "medication": { "clientManaged": boolean }
* }
*
* The schema is intentionally additive — every new category grows the
* schema with a new optional sub-object plus an entry in
* `DEFAULT_NOTIFICATION_PREFS`. Forward-compat: an unknown / drifted
* shape falls back to defaults rather than throwing.
*/
import { z } from "zod/v4";
/**
* Medication category schema. Currently a single boolean
* (`clientManaged`); the sub-object exists so future per-category
* knobs (e.g. quiet-hours overrides) can be added without breaking
* the persisted layout.
*/
const medicationPrefsSchema = z
.object({
clientManaged: z.boolean(),
})
.partial();
/**
* Top-level prefs schema. Every category is optional so a PATCH that
* only touches `medication` doesn't have to re-state future siblings.
* The route layer deep-merges the partial input over the persisted
* row before persisting so the column shape stays stable.
*/
export const notificationPrefsSchema = z
.object({
medication: medicationPrefsSchema,
})
.partial();
export type NotificationPrefsInput = z.infer<typeof notificationPrefsSchema>;
/**
* Fully-resolved prefs. Every key required so consumers (cron
* suppression, GET response) don't need to thread "is this present?"
* checks through their paths.
*/
export interface NotificationPrefs {
medication: {
clientManaged: boolean;
};
}
/**
* Safe defaults. Every category is off so the server reminders flow
* unchanged for any user the iOS app has not explicitly opted in.
* Marc's directive: zero regression for clients without working
* local reminders.
*/
export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = {
medication: {
clientManaged: false,
},
};
/**
* Parse the persisted `notificationPrefs` JSON blob into a typed
* `NotificationPrefs`, falling back to the defaults when the row is
* null OR the persisted shape has drifted (a forward-compat field
* rename, an admin-side hand-edit, etc.). Missing keys are filled
* from the defaults so callers always get a fully-resolved object.
*/
export function parseNotificationPrefs(raw: unknown): NotificationPrefs {
if (raw == null) return cloneDefaults();
const parsed = notificationPrefsSchema.safeParse(raw);
if (!parsed.success) return cloneDefaults();
return mergeOverDefaults(parsed.data);
}
/**
* Deep-merge a partial input (typically from a PATCH body) over the
* current persisted row, layering each supplied category's keys over
* the existing category sub-object so future siblings are not
* overwritten when the client only PATCHes `medication`.
*/
export function resolveNotificationPrefs(
current: unknown,
incoming: NotificationPrefsInput,
): NotificationPrefs {
const base = parseNotificationPrefs(current);
return {
medication: {
...base.medication,
...(incoming.medication ?? {}),
},
};
}
/**
* Cron-side helper. Returns `true` when the user has opted in to
* client-managed medication reminders and the server-side push
* should be suppressed. Tolerates a null / missing prefs row.
*/
export function isMedicationReminderClientManaged(raw: unknown): boolean {
return parseNotificationPrefs(raw).medication.clientManaged === true;
}
function cloneDefaults(): NotificationPrefs {
return {
medication: { ...DEFAULT_NOTIFICATION_PREFS.medication },
};
}
function mergeOverDefaults(
input: NotificationPrefsInput,
): NotificationPrefs {
return {
medication: {
...DEFAULT_NOTIFICATION_PREFS.medication,
...(input.medication ?? {}),
},
};
}
+22 -4
View File
@@ -290,9 +290,15 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
expect(stored).toBe(0);
});
it("dedupes a re-posted batch into duplicate status without inserting twice", async () => {
it("collapses same-instant cross-source twins via write-time dedup, then surfaces re-posts as duplicates", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
// Two entries share `sportType`, `startedAt`, and `source`. The
// v1.4.42 write-time cross-source picker (`dedupeWorkoutBatch`)
// groups by `(userId, activityType, startedAt ± 90 s)` and keeps
// ONE canonical row — the other lands as `duplicate` BEFORE
// reaching the DB. This is the Apple Watch + Withings ScanWatch
// case the picker exists to handle.
const body = {
workouts: [
baseWorkout("hk-uuid-dup-1"),
@@ -302,8 +308,15 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
const first = await POST(makeRequest(body));
expect(first.status).toBe(200);
const firstJson = (await first.json()) as { data: { inserted: number } };
expect(firstJson.data.inserted).toBe(2);
const firstJson = (await first.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
expect(firstJson.data.inserted).toBe(1);
expect(firstJson.data.duplicates).toBe(1);
const second = await POST(makeRequest(body));
expect(second.status).toBe(200);
@@ -314,6 +327,9 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
entries: Array<{ status: string }>;
};
};
// The write-time dedup still fires on the second post (one twin
// collapses), and the surviving twin now collides with the row
// persisted by the first post — so both end up as `duplicate`.
expect(secondJson.data.inserted).toBe(0);
expect(secondJson.data.duplicates).toBe(2);
expect(secondJson.data.entries.every((e) => e.status === "duplicate")).toBe(
@@ -323,7 +339,9 @@ describe("POST /api/workouts/batch (real Postgres)", () => {
const stored = await getPrismaClient().workout.count({
where: { userId: TEST_USER_ID },
});
expect(stored).toBe(2);
// Only the first-post survivor lives in the DB — write-time dedup
// dropped the other twin before any DB write.
expect(stored).toBe(1);
});
it("replays a cached response when the same Idempotency-Key is reused", async () => {
+13 -8
View File
@@ -177,13 +177,17 @@ describe("POST /api/workouts/batch — concurrent-write race", () => {
it("two batches with one overlapping externalId resolve to a single DB row each", async () => {
const { POST } = await import("@/app/api/workouts/batch/route");
// Batch A — entries 0..3 unique-to-A plus one shared key.
// Batch A — two entries one minute apart plus one shared key. The
// v1.4.42 `dedupeWorkoutBatch` picker collapses the minute-1 +
// minute-2 pair (same activity, within the 90 s window) to one
// surviving row, leaving 2 effective rows per batch.
const batchA: WorkoutFixture[] = [
workout("hk-uuid-only-a-0", 1),
workout("hk-uuid-only-a-1", 2),
workout("hk-uuid-shared", 10),
];
// Batch B — entries 0..3 unique-to-B plus the SAME shared key.
// Batch B — same shape, different externalIds for the unique pair
// but the SAME shared key.
const batchB: WorkoutFixture[] = [
workout("hk-uuid-only-b-0", 21),
workout("hk-uuid-only-b-1", 22),
@@ -202,16 +206,17 @@ describe("POST /api/workouts/batch — concurrent-write race", () => {
};
const bJson = (await resB.json()) as typeof aJson;
// Five unique tuples total across both batches; one shared. The
// composite unique index means the shared key lands exactly once,
// either as A's "inserted" or B's "inserted" — never both. The
// sum of the two batches' inserted counts is exactly 5.
expect(aJson.data.inserted + bJson.data.inserted).toBe(5);
// Three unique tuples land in the DB after write-time dedup + the
// composite unique index: one survivor from A's minute-1/2 pair,
// one survivor from B's minute-21/22 pair, and one shared-key row
// (either as A's "inserted" or B's "inserted" — never both). The
// sum of the two batches' inserted counts equals the row count.
expect(aJson.data.inserted + bJson.data.inserted).toBe(3);
const stored = await getPrismaClient().workout.findMany({
where: { userId: TEST_USER_ID },
});
expect(stored).toHaveLength(5);
expect(stored).toHaveLength(3);
const sharedRows = stored.filter(
(r) => r.externalId === "hk-uuid-shared",
);