mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
30ab2112db
commit
f92e644154
87 files changed
+7939
-2950
No files matched your search
+130
-56
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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).
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" } });
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user