mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
chore(release): v1.4.47 — audit-backlog closure + drag-to-reorder + Coach disable + OAuth nonce ledger
Eight changes: W0 idempotency test pin, W1 legacy column drop, W2 Textarea primitive, W3 Coach disable toggle, W4 dnd-kit drag-to-reorder, W5 onboarding 24h gate, W6 Withings OAuth nonce ledger, W7+W8 legacy_form_total drop + Coach onLine pre-check. 6-axis QA reconciled. Migrations 0076/0077/0078 required pre-image. Tests 5093 → ~5170.
This commit is contained in:
1 parent
ac319c6160
commit
66a7f8b6fa
68 files changed
+3959
-715
No files matched your search
@@ -0,0 +1,76 @@
|
||||
# Phase W2 — Textarea primitive extraction (v1.4.47)
|
||||
|
||||
## Goal
|
||||
Close the v1.4.43 mobile-UI audit L3 finding: six call-sites were each maintaining their own copy-pasted className with the `text-base sm:text-sm` iOS-zoom defence, drift risk on every edit. Extract a shadcn-style `<Textarea>` primitive parallel to `<Input>`, migrate the affected call-sites, lock the contract behind unit tests.
|
||||
|
||||
## What landed
|
||||
|
||||
### New primitive: `src/components/ui/textarea.tsx`
|
||||
Mirror of `<Input>` for multi-line input. Baked-in defaults:
|
||||
|
||||
- **iOS zoom defence** — `text-base sm:text-sm` (16 px on mobile, 14 px on `sm+`). Safari zooms the viewport on focus when the rendered font is below 16 px and yanks the keyboard up; this defends every consumer without each site having to repeat the dance.
|
||||
- **WCAG 2.5.5 tap-target floor** — `min-h-11 sm:min-h-9` (44 px on touch, 36 px on pointer). Lets `rows={1}` consumers stay above the tap-target floor without manually overriding.
|
||||
- **`autoCapitalize="sentences"`** — free-text fields read as prose on iOS / Android by default. Caller overrides with `"none"` for code / JSON / IDs.
|
||||
- **`spellCheck={true}`** — same reasoning; caller opts out for structured input.
|
||||
- **`autoComplete="off"` + `data-lpignore` / `data-1p-ignore`** — same posture as `<Input>`: HealthLog is a health-data app, so password managers don't get to paste the user's saved password into a free-text note that we then persist server-side.
|
||||
- **`forwardRef`** so consumers can attach refs (auto-grow, focus management, …).
|
||||
- **`data-slot="textarea"`** for the shadcn convention.
|
||||
- **`cn(…)` composition** — caller className wins via tailwind-merge, defaults still apply.
|
||||
|
||||
Plus the standard focus / aria-invalid / disabled treatment that mirrors `<Input>`.
|
||||
|
||||
### New tests: `src/components/ui/__tests__/textarea.test.tsx`
|
||||
11 tests, all green. Locks the contract:
|
||||
|
||||
- iOS zoom defence class present (`text-base` + `sm:text-sm`).
|
||||
- WCAG tap-target floor present (`min-h-11` + `sm:min-h-9`).
|
||||
- `autoComplete` defaults to `"off"` with the password-manager ignore attrs.
|
||||
- `autoCapitalize` defaults to `"sentences"`.
|
||||
- `spellCheck` defaults to `true`.
|
||||
- `data-slot="textarea"` attribute.
|
||||
- Caller overrides for `autoCapitalize` / `spellCheck` are honoured (JSON-paste case).
|
||||
- Caller overrides for `autoComplete` drop the ignore attrs.
|
||||
- Caller className merges via `cn()` without losing defaults.
|
||||
- `React.forwardRef` sentinel verified (sentinel-symbol check, dependency-free — no testing-library needed).
|
||||
- `rows` / `placeholder` / `maxLength` forwarded to the underlying element.
|
||||
|
||||
## Call-sites migrated
|
||||
|
||||
| File | Lines saved | Notes |
|
||||
| --- | --- | --- |
|
||||
| `src/app/bugreport/page.tsx:202` | -3 (comment + comment + 211-char className) | Plain free-text bug description; primitive defaults are a 1:1 fit. |
|
||||
| `src/app/medications/page.tsx:468` | -3 inline className + 2 comment | JSON paste — explicitly disables `autoCapitalize` + `spellCheck`, keeps the `font-mono` override via `className`. |
|
||||
| `src/components/medications/SideEffectsSection.tsx:467` | -3 inline className + 2 comment + 2 redundant attrs | Compact notes-row inside side-effect modal; `px-2 py-1.5` padding override preserved via `className`. Dropped the redundant explicit `autoCapitalize="sentences"` / `autoComplete="off"` (now the default). |
|
||||
| `src/components/admin/feedback-inbox-section.tsx:498` | -3 (comment + comment + 211-char className) | Admin internal-note textarea; primitive defaults are a 1:1 fit. |
|
||||
|
||||
**Total**: ~16 lines of copy-paste removed; **852 chars** of repeated className strings eliminated.
|
||||
|
||||
## Out of scope (intentional)
|
||||
|
||||
- **`src/components/insights/coach-panel/coach-input.tsx:188`** — included in the audit's "6+" count, but it's a fundamentally different design (auto-grow composer card wrapped in an outer "fake input" div with its own border + focus-within ring + Dracula-purple ring on focus, transparent textarea bg, custom `max-h-[9.5rem]` cap with internal overflow). Migrating it would require nullifying nearly every primitive default (border, bg, focus-ring, min-h, padding, shadow) — net worse than the inline pattern. Left as-is.
|
||||
- `<Input>` modifications — separate concern per scope.
|
||||
|
||||
## Quality gates
|
||||
|
||||
- `pnpm typecheck` — green.
|
||||
- `pnpm lint` on the touched files — green (one unrelated pre-existing warning in a withings test file remains).
|
||||
- `pnpm test src/components/ui/__tests__/textarea.test.tsx` — 11/11 green.
|
||||
- `pnpm test src/components/ui/__tests__/ src/components/medications/__tests__/SideEffectsSection.test.tsx` — 43/43 green; no regression in the suites that touch the migrated files.
|
||||
- `npx prettier --check` on the touched files — clean after a `--write` pass.
|
||||
- `npx knip` — clean (no unused exports / files).
|
||||
- Full `pnpm test` — 5101 / 5104 pass; the 2 failing tests (`/api/dashboard/summary` idempotency + `/api/medications/intake` double-mint) are confirmed pre-existing (fail on origin/develop unchanged, verified via `git stash`). Not in this scope.
|
||||
|
||||
## Branch
|
||||
|
||||
`worktree-agent-a135dd75a71c47561` — branched from `origin/develop` (HEAD `690cfaf0`).
|
||||
|
||||
## Files touched
|
||||
|
||||
- **NEW** `src/components/ui/textarea.tsx` (+82 LOC)
|
||||
- **NEW** `src/components/ui/__tests__/textarea.test.tsx` (+93 LOC)
|
||||
- **EDIT** `src/app/bugreport/page.tsx` (-3 textarea LOC, +1 import)
|
||||
- **EDIT** `src/app/medications/page.tsx` (-3 textarea LOC, +1 import, +3 explicit JSON overrides)
|
||||
- **EDIT** `src/components/medications/SideEffectsSection.tsx` (-5 textarea LOC, +1 import)
|
||||
- **EDIT** `src/components/admin/feedback-inbox-section.tsx` (-3 textarea LOC, +1 import)
|
||||
|
||||
Net: +169 LOC primitive + tests, -14 LOC inline copy-paste, with the audit's drift-risk surface area now closed behind a single contract + test file.
|
||||
@@ -1,5 +1,52 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.47] — 2026-05-22 — Audit-backlog closure: drag-to-reorder, Coach disable toggle, OAuth state nonce table, legacy column drop, primitive sweep
|
||||
|
||||
v1.4.45 closed the v1.4.43-audit follow-up; v1.4.46 caught a same-day server reconcile (PR worker, intake auto-skip, APNS admin test). v1.4.47 is the dedicated follow-up that lands every deferred v1.4.45 audit Medium/Low plus the W14 legacy-column cleanup the v1.4.45 release scheduled for "one release later".
|
||||
|
||||
Eight changes landed on `develop` before this release commit:
|
||||
|
||||
- **W0** wall-clock pin on the two idempotency tests (`/api/dashboard/summary` + `/api/medications/intake`) that hard-coded `2026-05-21` and broke on the 22nd
|
||||
- **W1** drop the legacy `consecutive_failures` column on `integration_statuses` — the v1.4.45 W14 per-kind bucket migration carried the legacy integer for one release as a fallback; now removed, alert ladder reads `Math.max(...buckets)`
|
||||
- **W2** extract `<Textarea>` primitive with iOS-zoom defence + WCAG tap-target floor; sweep 4 inline call-sites (bugreport, medication JSON paste, side-effects notes, admin feedback)
|
||||
- **W3** per-user Coach disable toggle in Settings → Insights; survives `flags.coach` (admin) gate at all five mount points; new `disableCoach` column + audit-logged PATCH endpoint
|
||||
- **W4** dashboard widget drag-to-reorder via `@dnd-kit/sortable` (a11y arrow-button fallback preserved); 6 new locale strings; new `reorderWidgets` pure helper
|
||||
- **W5** dashboard tour auto-launch gated on `onboardingCompletedAt + 24 h` so the carousel and tour no longer chain immediately; "Replay the tour" CTA added to Settings → About
|
||||
- **W6** Withings OAuth `state` cookie no longer encodes `${userId}:${nonce}` — switched to a 16-byte random nonce + short-lived `WithingsOAuthState` ledger row + 03:20 cleanup cron, closing v1.4.43 audit security L-1
|
||||
- **W7** drop the in-memory `legacy_form_total` counter (per-process + useless on multi-container deploys; access-log warning still emits)
|
||||
- **W8** Coach client pre-checks `navigator.onLine` before fetching `/api/insights/chat` so an airplane-mode user gets the offline-specific `coach.network` copy immediately
|
||||
|
||||
### Added
|
||||
|
||||
- **`<Textarea>` primitive** (`src/components/ui/textarea.tsx`) — mirrors `<Input>`'s shape with `forwardRef` + `data-slot="textarea"`. Bakes in `text-base sm:text-sm` iOS-zoom defence, `min-h-11 sm:min-h-9` tap target floor, `autoCapitalize="sentences"`, `spellCheck={true}`, `autoComplete="off"` + password-manager-ignore data-attributes. 11 unit tests pin the contract.
|
||||
- **Per-user Coach disable toggle** — Settings → Insights "Coach ausblenden" / "Hide Coach" Switch (W3). `disableCoach` Boolean column on `User` (migration `0078_v1447_user_disable_coach`). `GET /api/auth/me/disable-coach` + `PATCH /api/auth/me/disable-coach` (60/min/user rate-limit + audit row on every state-changing call). Mount gates on `<CoachFab>`, `<CoachMount>`, `<CoachLaunchButton>`, `<SuggestedPrompts>`, hero-strip `<HealthScoreCard onAskCoach>`, `/targets` per-card CTAs. `useDisableCoach()` SSR-safe hook mirrors `useFeatureFlags()`'s defensive pattern.
|
||||
- **Dashboard drag-to-reorder** — `@dnd-kit/core` + `@dnd-kit/sortable` + `@dnd-kit/utilities` runtime deps (~29 KB gzipped). `<SortableWidgetRow>` wrapper, `closestCenter` collision, vertical-list strategy, `KeyboardSensor` + `sortableKeyboardCoordinates` for full keyboard accessibility. Drag handle is a `<GripVertical>` icon button on every row's leading edge with `cursor-grab` + `touch-none` + 6 px activation distance. Existing arrow buttons survive as the a11y fallback. `reorderWidgets()` pure helper exported for unit-testing the contract without a DOM. 6 locale strings (`dashboard.dragHandle` + `dashboard.dragHandleHint`).
|
||||
- **`<OfflineBanner>`'s sibling** — Coach send-message path now pre-checks `navigator.onLine` and short-circuits to the `coach.network` error code before the fetch attempt, so the user sees the offline-specific banner copy immediately instead of waiting on a generic network failure (W8).
|
||||
- **Withings OAuth state ledger** — new `WithingsOAuthState` model + `withings_oauth_states` table with `nonce` PK, `userId` FK (`ON DELETE CASCADE`), `expiresAt` index. Connect mints a row with `randomBytes(16).toString("base64url")` + 10 min TTL; callback verifies + DELETEs on every exit branch (single-use). Cleanup cron `withings-oauth-state-cleanup` runs daily at 03:20 Europe/Berlin via `reminder-worker.ts`. New `src/lib/withings/oauth-state.ts` module exports the shared constants + minter.
|
||||
- **"Replay the tour" CTA** in Settings → About (`<AboutSection>`). Clicking re-arms a force-launch sessionStorage marker the `<TourLauncher>` consumes on its next mount, bypassing the new 24 h auto-launch gate. Also wired into the existing "Restart onboarding tour" button in `<AccountSection>` so a same-day click after navigation still lands on the tour.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`/api/auth/me` payload** extended with `disableCoach: boolean` (defaulted to `false` for partial-deploy rollback). `useAuth` types extended; the fetcher coerces `undefined → false` at the wire boundary.
|
||||
- **`IntegrationStatus.consecutiveFailures` column dropped** — the v1.4.45 W14 migration introduced per-kind buckets but kept the legacy integer one release as a fallback. v1.4.47 drops the column. Alert ladder + audit `attemptNumber` now read `Math.max(...Object.values(consecutiveFailuresByKind))`. Migration `0077_v1447_drop_legacy_consecutive_failures` is reversible via a `GREATEST(transient, reauth_required, persistent)` recipe documented inline.
|
||||
- **`<TourLauncher>` auto-launch gate** extended from "tour not completed" to "tour not completed AND `onboardingCompletedAt + 24 h < now()`". The mount-time clock is captured via `useState(() => Date.now())` so render stays pure. Brand-new users (`onboardingCompletedAt == null`) and same-day re-visits never see the auto-launch; the manual "Replay the tour" button still works.
|
||||
- **`messages/de.json`** + 5 sibling locales — added `dashboard.dragHandle` + `dashboard.dragHandleHint`, `settings.ai.disableCoach.{title,description,toggleAria,savedHidden,savedShown,saveError}`, `settings.about.tourReplay` + `settings.about.tourReplayHint`. Copy is tight + professional in every locale; English-fallback on `tourReplay` for es/fr/it/pl per the partial-translation status the rest of the About section already carries.
|
||||
- **Withings `state` cookie name preserved** as `withings_state`; the value shape changed from `${userId}:${nonce}` to a bare 22-char base64url nonce. A handshake mid-deploy will fail the CSRF check on the callback side (the cookie carries the old shape, the ledger has no row) and bounce the user to the connect-error page; a retry succeeds. No data loss; users in flight retry once.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Dashboard summary + medications/intake idempotency tests** broke on 2026-05-22 because they hard-coded `2026-05-21` while the route's projection used `new Date()`. `vi.setSystemTime` pins both ends to the same calendar day so the regression guard stays stable on every future test run (W0).
|
||||
- **Coach send-while-offline UX** — airplane-mode user no longer sees a delayed generic network error; the `<MessageThread>` now surfaces the `coach.network` copy immediately (W8). Surfaces the v1.4.45 W12 M6 errorNetwork key properly on the client side.
|
||||
- **`legacy_form_total` counter removed** — `withingsWebhookLegacyFormTotal` no longer exposed on `/api/admin/status`. Per-process in-memory counters were never accurate across the multi-container deploy; the access-log warning remains the operator signal (W7).
|
||||
|
||||
### Operator notes
|
||||
|
||||
- **Migration MUST run before the app image rolls**: three migrations apply via `prisma migrate deploy` in numeric order: `0076_v1447_withings_oauth_state`, `0077_v1447_drop_legacy_consecutive_failures`, `0078_v1447_user_disable_coach`. All idempotent (`IF NOT EXISTS` / `IF EXISTS`) + reversible. Running the v1.4.47 image without migration 0078 would 500 every `/api/auth/me` call because the Prisma client SELECTs `disable_coach`; run `prisma migrate deploy` first.
|
||||
- **New cron**: `withings-oauth-state-cleanup` runs daily at 03:20 Europe/Berlin via pg-boss. Wired into `reminder-worker.ts`; no extra ops setup required beyond a worker container running.
|
||||
- **No env-var change.** `pnpm check-env` still passes; the v1.4.42 env-check CI gate enforces manifest ↔ `.env.production.example` lockstep.
|
||||
- **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)
|
||||
|
||||
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.
|
||||
|
||||
+14
-2
@@ -275,6 +275,8 @@
|
||||
"moveDown": "Nach unten",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -1867,7 +1869,15 @@
|
||||
},
|
||||
"testProvider": "Aktiven Provider testen",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test fehlgeschlagen: {message}"
|
||||
"testFailedShort": "Test fehlgeschlagen: {message}",
|
||||
"disableCoach": {
|
||||
"title": "Coach ausblenden",
|
||||
"description": "Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
|
||||
"toggleAria": "Coach-Button und Coach-Panel ausblenden",
|
||||
"savedHidden": "Coach ist ausgeblendet.",
|
||||
"savedShown": "Coach ist sichtbar.",
|
||||
"saveError": "Speichern fehlgeschlagen. Bitte erneut versuchen."
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Verbinde deine Withings-Waage und Blutdruckmessgeräte.",
|
||||
@@ -1994,7 +2004,9 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Dokumentation",
|
||||
"linksHeading": "Quellen & Dokumentation",
|
||||
"newerAvailable": "Neue Version: {tag}"
|
||||
"newerAvailable": "Neue Version: {tag}",
|
||||
"tourReplay": "Tour ansehen",
|
||||
"tourReplayHint": "Zeige die Dashboard-Tour — praktisch, falls du sie beim ersten Besuch übersprungen hast."
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Verbindung testen",
|
||||
|
||||
+14
-2
@@ -275,6 +275,8 @@
|
||||
"moveDown": "Move down",
|
||||
"layoutTileColumn": "Tile",
|
||||
"layoutChartColumn": "Chart",
|
||||
"dragHandle": "Drag to reorder",
|
||||
"dragHandleHint": "Drag the handle to reorder this widget. You can also use the Up and Down arrow buttons, or focus the handle and press Space and the arrow keys.",
|
||||
"emptyTitle": "Your dashboard is empty",
|
||||
"emptyDescription": "Log your first measurement or mood entry. Tiles and charts appear automatically as data lands.",
|
||||
"emptyAddMeasurement": "Add your first measurement",
|
||||
@@ -1867,7 +1869,15 @@
|
||||
},
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}"
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"disableCoach": {
|
||||
"title": "Hide Coach",
|
||||
"description": "Hides the Coach button and drawer everywhere.",
|
||||
"toggleAria": "Hide the Coach button and drawer",
|
||||
"savedHidden": "Coach hidden.",
|
||||
"savedShown": "Coach visible.",
|
||||
"saveError": "Could not save the change. Please try again."
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
|
||||
@@ -1994,7 +2004,9 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"tourReplay": "Replay the tour",
|
||||
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
|
||||
+14
-2
@@ -275,6 +275,8 @@
|
||||
"moveDown": "Bajar",
|
||||
"layoutTileColumn": "Tarjeta",
|
||||
"layoutChartColumn": "Gráfico",
|
||||
"dragHandle": "Arrastra para reordenar",
|
||||
"dragHandleHint": "Arrastra el manejador para reordenar este widget. También puedes usar los botones de flecha Arriba y Abajo, o enfocar el manejador y pulsar Espacio más las flechas.",
|
||||
"emptyTitle": "Tu panel está vacío",
|
||||
"emptyDescription": "Registra tu primera medición o estado de ánimo. Las tarjetas y gráficas aparecerán automáticamente en cuanto haya datos.",
|
||||
"emptyAddMeasurement": "Registrar la primera medición",
|
||||
@@ -1867,7 +1869,15 @@
|
||||
},
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}"
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"disableCoach": {
|
||||
"title": "Ocultar el Coach",
|
||||
"description": "Oculta el botón del Coach y el panel en todas las páginas.",
|
||||
"toggleAria": "Ocultar el botón del Coach y el panel",
|
||||
"savedHidden": "Coach oculto.",
|
||||
"savedShown": "Coach visible.",
|
||||
"saveError": "No se pudo guardar el cambio. Inténtalo de nuevo."
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
|
||||
@@ -1994,7 +2004,9 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"tourReplay": "Replay the tour",
|
||||
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
|
||||
+14
-2
@@ -275,6 +275,8 @@
|
||||
"moveDown": "Descendre",
|
||||
"layoutTileColumn": "Tuile",
|
||||
"layoutChartColumn": "Graphique",
|
||||
"dragHandle": "Glisser pour réordonner",
|
||||
"dragHandleHint": "Glisse la poignée pour réordonner ce widget. Tu peux aussi utiliser les flèches Haut et Bas, ou cibler la poignée puis appuyer sur Espace plus les flèches.",
|
||||
"emptyTitle": "Ton tableau de bord est vide",
|
||||
"emptyDescription": "Saisis ta première mesure ou humeur. Les tuiles et graphiques apparaissent automatiquement dès qu’il y a des données.",
|
||||
"emptyAddMeasurement": "Saisir la première mesure",
|
||||
@@ -1867,7 +1869,15 @@
|
||||
},
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}"
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"disableCoach": {
|
||||
"title": "Masquer le Coach",
|
||||
"description": "Masque le bouton et le panneau du Coach sur toutes les pages.",
|
||||
"toggleAria": "Masquer le bouton et le panneau du Coach",
|
||||
"savedHidden": "Coach masqué.",
|
||||
"savedShown": "Coach visible.",
|
||||
"saveError": "Impossible d'enregistrer la modification. Veuillez réessayer."
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
|
||||
@@ -1994,7 +2004,9 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"tourReplay": "Replay the tour",
|
||||
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
|
||||
+14
-2
@@ -275,6 +275,8 @@
|
||||
"moveDown": "Sposta giù",
|
||||
"layoutTileColumn": "Tile",
|
||||
"layoutChartColumn": "Grafico",
|
||||
"dragHandle": "Trascina per riordinare",
|
||||
"dragHandleHint": "Trascina la maniglia per riordinare questo widget. Puoi anche usare i pulsanti freccia Su e Giù, oppure mettere a fuoco la maniglia e premere Spazio e i tasti freccia.",
|
||||
"emptyTitle": "La tua dashboard è vuota",
|
||||
"emptyDescription": "Registra la prima misurazione o un umore. Tile e grafici appaiono automaticamente non appena ci sono dati.",
|
||||
"emptyAddMeasurement": "Registra la prima misurazione",
|
||||
@@ -1867,7 +1869,15 @@
|
||||
},
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}"
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"disableCoach": {
|
||||
"title": "Nascondi il Coach",
|
||||
"description": "Nasconde il pulsante e il pannello del Coach su tutte le pagine.",
|
||||
"toggleAria": "Nascondi il pulsante e il pannello del Coach",
|
||||
"savedHidden": "Coach nascosto.",
|
||||
"savedShown": "Coach visibile.",
|
||||
"saveError": "Impossibile salvare la modifica. Riprova."
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
|
||||
@@ -1994,7 +2004,9 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"tourReplay": "Replay the tour",
|
||||
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
|
||||
+14
-2
@@ -275,6 +275,8 @@
|
||||
"moveDown": "W dół",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -1867,7 +1869,15 @@
|
||||
},
|
||||
"testProvider": "Test active provider",
|
||||
"testSuccess": "OK — {provider} ({model})",
|
||||
"testFailedShort": "Test failed: {message}"
|
||||
"testFailedShort": "Test failed: {message}",
|
||||
"disableCoach": {
|
||||
"title": "Ukryj Coacha",
|
||||
"description": "Ukrywa przycisk Coacha i panel na wszystkich stronach.",
|
||||
"toggleAria": "Ukryj przycisk Coacha i panel",
|
||||
"savedHidden": "Coach ukryty.",
|
||||
"savedShown": "Coach widoczny.",
|
||||
"saveError": "Nie udało się zapisać zmiany. Spróbuj ponownie."
|
||||
}
|
||||
},
|
||||
"withings": "Withings",
|
||||
"withingsDescription": "Connect your Withings scale and blood pressure monitors.",
|
||||
@@ -1994,7 +2004,9 @@
|
||||
"changelog": "Changelog",
|
||||
"docs": "Documentation",
|
||||
"linksHeading": "Sources & docs",
|
||||
"newerAvailable": "Newer version: {tag}"
|
||||
"newerAvailable": "Newer version: {tag}",
|
||||
"tourReplay": "Replay the tour",
|
||||
"tourReplayHint": "Watch the dashboard walkthrough — handy if you skipped it on first visit."
|
||||
},
|
||||
"testConnection": {
|
||||
"test": "Test connection",
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.46",
|
||||
"version": "1.4.47",
|
||||
"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",
|
||||
@@ -50,6 +50,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1048.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@parse/node-apn": "^8.1.0",
|
||||
|
||||
Generated
+56
@@ -11,6 +11,15 @@ importers:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.1048.0
|
||||
version: 3.1048.0
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
version: 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@dnd-kit/sortable':
|
||||
specifier: ^10.0.0
|
||||
version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.5)
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.2.2
|
||||
version: 5.2.2(react-hook-form@7.75.0(react@19.2.5))
|
||||
@@ -476,6 +485,28 @@ packages:
|
||||
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1':
|
||||
resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/core@6.3.1':
|
||||
resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
react-dom: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/sortable@10.0.0':
|
||||
resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==}
|
||||
peerDependencies:
|
||||
'@dnd-kit/core': ^6.3.0
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dnd-kit/utilities@3.2.2':
|
||||
resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==}
|
||||
peerDependencies:
|
||||
react: '>=16.8.0'
|
||||
|
||||
'@dotenvx/dotenvx@1.65.0':
|
||||
resolution: {integrity: sha512-v4FA/Lw3pTEloLxBqTOaYDX6MNo0Jo7lGBsPZhwnJBqRJp0AzQg1ZZNxrFsh6HVC6QWeWrfIKLn0y2eyIXaVDg==}
|
||||
hasBin: true
|
||||
@@ -6884,6 +6915,31 @@ snapshots:
|
||||
|
||||
'@discoveryjs/json-ext@0.5.7': {}
|
||||
|
||||
'@dnd-kit/accessibility@3.1.1(react@19.2.5)':
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@dnd-kit/accessibility': 3.1.1(react@19.2.5)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.5)
|
||||
react: 19.2.5
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
|
||||
dependencies:
|
||||
'@dnd-kit/core': 6.3.1(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
'@dnd-kit/utilities': 3.2.2(react@19.2.5)
|
||||
react: 19.2.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/utilities@3.2.2(react@19.2.5)':
|
||||
dependencies:
|
||||
react: 19.2.5
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dotenvx/dotenvx@1.65.0':
|
||||
dependencies:
|
||||
commander: 11.1.0
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
-- v1.4.47 W6 — Withings OAuth state nonce ledger.
|
||||
--
|
||||
-- The v1.4.43 security audit (L-1) flagged the legacy state shape
|
||||
-- `${user.id}:${random16}` as cosmetic-but-fragile: the user id is
|
||||
-- recoverable from any request log entry where the cookie isn't
|
||||
-- redacted, and from a network-traffic capture of the redirect. The
|
||||
-- cookie is `httpOnly` + `Secure` + `sameSite:lax` today so the XSS
|
||||
-- exfiltration surface is closed — but a future refactor that flips
|
||||
-- the cookie to a non-httpOnly variant would silently expose the user
|
||||
-- id. Audit recommendation was to switch the state to a fully-random
|
||||
-- 16-byte nonce backed by a short-lived `(nonce → userId)` row.
|
||||
--
|
||||
-- This table is that ledger. Each `withings/connect` mint persists one
|
||||
-- row; `withings/callback` looks it up, asserts `expiresAt > now()`,
|
||||
-- and DELETEs the row in a single transaction so a replay of the same
|
||||
-- state nonce against a fresh user session fails the CSRF check (the
|
||||
-- row is gone after first consumption). A daily cron sweeps any
|
||||
-- abandoned rows whose `expiresAt` has passed (user closed the
|
||||
-- Withings tab without completing the handshake).
|
||||
--
|
||||
-- Schema choices:
|
||||
--
|
||||
-- * `nonce` is the primary key — it's already random and
|
||||
-- 22-character base64url, so a dedicated `id` column would just
|
||||
-- duplicate the key without buying anything.
|
||||
-- * `userId` carries `ON DELETE CASCADE` so removing a user cleans
|
||||
-- up their in-flight nonces atomically.
|
||||
-- * `expiresAt` is indexed for the cleanup sweep — without it the
|
||||
-- daily `DELETE … WHERE expiresAt < now()` would seq-scan the
|
||||
-- entire table once a day even though the typical row count is
|
||||
-- bounded by `users × concurrent in-flight handshakes`.
|
||||
--
|
||||
-- Idempotent guards (`IF NOT EXISTS`) match the 0067 / 0070 / 0071 /
|
||||
-- 0074 / 0075 pattern so reruns are safe.
|
||||
--
|
||||
-- Reversibility: down migration is `DROP TABLE IF EXISTS
|
||||
-- "withings_oauth_states"`. Additive-only; no existing row is
|
||||
-- touched.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "withings_oauth_states" (
|
||||
"nonce" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "withings_oauth_states_pkey" PRIMARY KEY ("nonce")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "withings_oauth_states_expires_at_idx"
|
||||
ON "withings_oauth_states" ("expires_at");
|
||||
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "withings_oauth_states"
|
||||
ADD CONSTRAINT "withings_oauth_states_user_id_fkey"
|
||||
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
@@ -0,0 +1,49 @@
|
||||
-- v1.4.47 W1 — drop the legacy `consecutive_failures` column on
|
||||
-- `integration_statuses`.
|
||||
--
|
||||
-- v1.4.43 W14 introduced `consecutive_failures_by_kind` (JSONB) as
|
||||
-- the bucketed per-FailureKind counter and kept the single-column
|
||||
-- `consecutive_failures` integer for one release as a fallback so
|
||||
-- v1.4.43 readers wouldn't break mid-deploy. v1.4.45 W10 closed the
|
||||
-- write-time gap (back-fill increment posts the bucket and the legacy
|
||||
-- column at the same N+1 value). v1.4.47 W1 drops the legacy column
|
||||
-- now that every active integration in production has written at
|
||||
-- least one new failure or success since v1.4.43 shipped (>24h ago),
|
||||
-- so every row's `consecutive_failures_by_kind` is populated and the
|
||||
-- legacy integer is redundant.
|
||||
--
|
||||
-- After this migration the alert-ladder reader uses
|
||||
-- `Math.max(...Object.values(buckets))` exclusively — the legacy
|
||||
-- column is no longer consulted. `recordSyncFailure` and friends
|
||||
-- stop writing to `consecutive_failures` entirely (the corresponding
|
||||
-- application-level cleanup in `src/lib/integrations/status.ts`
|
||||
-- lands in the same release).
|
||||
--
|
||||
-- Idempotent guard (`IF EXISTS`) matches the 0075 pattern so reruns
|
||||
-- against an environment that has already dropped the column are
|
||||
-- safe.
|
||||
--
|
||||
-- Reversibility (down-migration):
|
||||
--
|
||||
-- ALTER TABLE "integration_statuses"
|
||||
-- ADD COLUMN IF NOT EXISTS "consecutive_failures" INTEGER
|
||||
-- NOT NULL DEFAULT 0;
|
||||
--
|
||||
-- UPDATE "integration_statuses"
|
||||
-- SET "consecutive_failures" = COALESCE(
|
||||
-- GREATEST(
|
||||
-- ("consecutive_failures_by_kind"->>'transient')::int,
|
||||
-- ("consecutive_failures_by_kind"->>'reauth_required')::int,
|
||||
-- ("consecutive_failures_by_kind"->>'persistent')::int
|
||||
-- ),
|
||||
-- 0
|
||||
-- )
|
||||
-- WHERE "consecutive_failures_by_kind" IS NOT NULL;
|
||||
--
|
||||
-- The down-script restores the legacy counter to `Math.max(...buckets)`
|
||||
-- — the same value the alert ladder reads from the buckets — so a
|
||||
-- rollback lands every row at the post-v1.4.43 counter value without
|
||||
-- losing the streak's true age.
|
||||
|
||||
ALTER TABLE "integration_statuses"
|
||||
DROP COLUMN IF EXISTS "consecutive_failures";
|
||||
@@ -0,0 +1,29 @@
|
||||
-- v1.4.47 W3 — per-user Coach opt-out.
|
||||
--
|
||||
-- Privacy-leaning users want to hide the Coach surface even when the
|
||||
-- operator has enabled the global `assistant.coach` feature flag. The
|
||||
-- per-user `disable_coach` column is a hard client-side gate consulted
|
||||
-- by every Coach mount point (`<LayoutCoachFab>`, `<LayoutCoachMount>`,
|
||||
-- `<CoachLaunchButton>`, `/targets` page CTA, …) AFTER the
|
||||
-- operator-level flag check — both must agree to render the affordance.
|
||||
--
|
||||
-- Default `false` keeps the existing posture: every user who has not
|
||||
-- explicitly opted out continues to see the Coach button + drawer
|
||||
-- exactly as before. The toggle lives in Settings → Insights as a
|
||||
-- `<Switch>` labelled "Hide Coach" / "Coach ausblenden"; persisted via
|
||||
-- `PATCH /api/auth/me/disable-coach`.
|
||||
--
|
||||
-- Idempotent guard (`IF NOT EXISTS`) matches the 0067 / 0070 / 0071 /
|
||||
-- 0075 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 "disable_coach";`. The column is `NOT NULL DEFAULT false`
|
||||
-- so the down migration is lossy (every opted-out user reverts to
|
||||
-- "Coach visible") but never crashes a roll-back. The application
|
||||
-- layer treats a missing column the same as `false` (the auth/me
|
||||
-- payload defaults the field) so a partial deploy where the schema
|
||||
-- is rolled back ahead of the app code keeps working.
|
||||
|
||||
ALTER TABLE "users"
|
||||
ADD COLUMN IF NOT EXISTS "disable_coach" BOOLEAN NOT NULL DEFAULT false;
|
||||
+91
-30
@@ -223,6 +223,23 @@ 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.47 W3 — per-user Coach opt-out (privacy-leaning users).
|
||||
//
|
||||
// When `true`, every Coach mount point on the web client renders
|
||||
// nothing — `<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline
|
||||
// `<CoachLaunchButton>` pill, the `/targets` page CTA, etc. The
|
||||
// gate sits BELOW the operator-level `flags.coach` check: both
|
||||
// must agree to render the affordance. Default `false` so the
|
||||
// upgrade from v1.4.46 is invisible to anyone who hasn't toggled
|
||||
// the new switch.
|
||||
//
|
||||
// Persistence point is the Settings → Insights `<Switch>` labelled
|
||||
// "Hide Coach" / "Coach ausblenden"; the toggle writes through
|
||||
// `PATCH /api/auth/me/disable-coach` and the next /api/auth/me
|
||||
// round-trip surfaces the new value to every Coach gate via the
|
||||
// `useAuth()` hook.
|
||||
disableCoach Boolean @default(false) @map("disable_coach")
|
||||
|
||||
// Relations
|
||||
passkeys Passkey[]
|
||||
sessions Session[]
|
||||
@@ -234,6 +251,13 @@ model User {
|
||||
refreshTokens RefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
withingsConnection WithingsConnection?
|
||||
// v1.4.47 W6 — short-lived `(nonce → userId)` ledger backing the
|
||||
// Withings OAuth state check. The connect route mints a row with a
|
||||
// 10-minute TTL; the callback resolves the user via this relation
|
||||
// instead of parsing the cookie value, closing the L-1 audit gap
|
||||
// where the legacy `${user.id}:${nonce}` shape leaked the user id
|
||||
// into request logs / network captures.
|
||||
withingsOAuthStates WithingsOAuthState[]
|
||||
moodEntries MoodEntry[]
|
||||
notificationChannels NotificationChannel[]
|
||||
pushSubscriptions PushSubscription[]
|
||||
@@ -251,25 +275,25 @@ model User {
|
||||
// session can build against a stable DTO. Routes hang off Workout
|
||||
// (1:1), not User — the User → WorkoutRoute path is two hops by
|
||||
// design.
|
||||
workouts Workout[]
|
||||
workouts Workout[]
|
||||
// v1.4.25 W8d — personal records (schema only; detection worker
|
||||
// lands in a later release). The GET /api/personal-records route
|
||||
// ships with v1.4.25 so the iOS app can build its query path
|
||||
// against a stable contract.
|
||||
personalRecords PersonalRecord[]
|
||||
personalRecords PersonalRecord[]
|
||||
// v1.4.25 W19b — per-pen / per-vial inventory entities, the
|
||||
// companion to the running-sum MedicationInventoryEvent ledger.
|
||||
// Carries the EMA §6.3 30-day in-use clock per opened pen.
|
||||
inventoryItems MedicationInventoryItem[]
|
||||
inventoryItems MedicationInventoryItem[]
|
||||
// v1.4.25 W19d — GLP-1 side-effect logbook. 21-entry × 5-category
|
||||
// EMA EPAR §4.8-derived taxonomy with 1-5 Likert severity. Daily
|
||||
// capture (not per-shot) so delayed-onset symptoms map to their
|
||||
// actual occurrence time.
|
||||
medicationSideEffects MedicationSideEffect[]
|
||||
medicationSideEffects MedicationSideEffect[]
|
||||
// v1.4.34 — Apple Health `export.zip` import jobs. Mirrors the
|
||||
// pg-boss job state so the polling endpoint can read live per-
|
||||
// phase progress without round-tripping through pg-boss internals.
|
||||
importJobs ImportJob[]
|
||||
importJobs ImportJob[]
|
||||
// v1.5.0 — persistent measurement rollup table. Carries the
|
||||
// count/min/max/mean/sd/slope/r2 stats per (type, granularity,
|
||||
// bucketStart) so the analytics + comprehensive read paths bypass
|
||||
@@ -277,19 +301,19 @@ model User {
|
||||
// multi-instance fan-out. Populated synchronously on the DAY
|
||||
// granularity from the measurement write hooks; WEEK/MONTH/YEAR
|
||||
// are enqueued through pg-boss for the worker to fold.
|
||||
measurementRollups MeasurementRollup[]
|
||||
measurementRollups MeasurementRollup[]
|
||||
// v0.5.4 ios-coord — per-day dedup ledger for MOOD_REMINDER pushes.
|
||||
// One row per (user, local date) the worker has nudged. Prevents
|
||||
// a second push when the cron rechecks the same hour later in the
|
||||
// 22:00 window (the cron fires every 15 min — without the ledger
|
||||
// a user could see three pushes back-to-back).
|
||||
moodReminderDispatches MoodReminderDispatch[]
|
||||
moodReminderDispatches MoodReminderDispatch[]
|
||||
// v1.4.39 W-MOOD — per-(user, granularity, bucket) mood rollup. New
|
||||
// tier behind the mood-analytics read path; per-write hooks fold
|
||||
// MoodEntry mutations into the DAY granularity inline so the next
|
||||
// read serves from this table instead of the unbounded
|
||||
// MoodEntry.findMany walk.
|
||||
moodEntryRollups MoodEntryRollup[]
|
||||
moodEntryRollups MoodEntryRollup[]
|
||||
// v1.4.39 W-MED — per-(user, medication, day) compliance rollup.
|
||||
// Per-write hook on MedicationIntakeEvent mutations + the scheduler
|
||||
// keep the row in sync so `/api/medications/intake?scope=compliance`
|
||||
@@ -301,7 +325,7 @@ model User {
|
||||
// be verifiable") plus the operator's GDPR Art. 7 documentation
|
||||
// burden. Append-only — a user revoking + re-granting consent mints
|
||||
// a fresh row so the timeline reads chronologically.
|
||||
consentReceipts ConsentReceipt[]
|
||||
consentReceipts ConsentReceipt[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -732,22 +756,22 @@ model Medication {
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
schedules MedicationSchedule[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
phaseConfig ReminderPhaseConfig?
|
||||
telegramMessages TelegramReminderMessage[]
|
||||
doseChanges MedicationDoseChange[]
|
||||
inventoryEvents MedicationInventoryEvent[]
|
||||
schedules MedicationSchedule[]
|
||||
intakeEvents MedicationIntakeEvent[]
|
||||
phaseConfig ReminderPhaseConfig?
|
||||
telegramMessages TelegramReminderMessage[]
|
||||
doseChanges MedicationDoseChange[]
|
||||
inventoryEvents MedicationInventoryEvent[]
|
||||
/// v1.4.25 W19b — per-pen / per-vial entities (state machine +
|
||||
/// 30-day in-use clock). Complements the running-sum
|
||||
/// MedicationInventoryEvent ledger.
|
||||
inventoryItems MedicationInventoryItem[]
|
||||
inventoryItems MedicationInventoryItem[]
|
||||
/// v1.4.25 W19d — structured GLP-1 side-effect logs. Surfaced on
|
||||
/// the medication detail page for GLP-1 treatmentClass only; the
|
||||
/// taxonomy follows EMA EPAR §4.8 with HealthLog's 5-category
|
||||
/// roll-up (GI / METABOLIC / INJECTION_SITE / COGNITIVE /
|
||||
/// GLP1_SPECIFIC) and 1-5 Likert severity per entry.
|
||||
sideEffects MedicationSideEffect[]
|
||||
sideEffects MedicationSideEffect[]
|
||||
/// v1.4.39 W-MED — per-(medication, day) compliance rollup. Mutations
|
||||
/// on the linked MedicationIntakeEvent rows refresh the row inline;
|
||||
/// the read paths consume this instead of the per-event findMany.
|
||||
@@ -1192,6 +1216,40 @@ model WithingsConnection {
|
||||
@@map("withings_connections")
|
||||
}
|
||||
|
||||
/// v1.4.47 W6 — short-lived ledger of in-flight OAuth state nonces.
|
||||
///
|
||||
/// The legacy state shape `${user.id}:${random16}` (v1.4.42-) persisted
|
||||
/// the user id in plaintext on both the URL state param and the
|
||||
/// `withings_state` cookie. The cookie is httpOnly + Secure +
|
||||
/// sameSite:lax today so XSS exfiltration is closed, but the user id
|
||||
/// is still recoverable from any request-log entry where the cookie
|
||||
/// isn't redacted and from a network-traffic capture of the redirect.
|
||||
/// The v1.4.43 security audit (L-1) recommended decoupling the two by
|
||||
/// switching the state to a fully-random 16-byte nonce backed by this
|
||||
/// short-lived row — the callback resolves the user via the row's
|
||||
/// `userId` instead of the cookie value.
|
||||
///
|
||||
/// Lifecycle:
|
||||
/// * `connect` mints a row with `expiresAt = now() + 10 min` and
|
||||
/// sets the cookie to JUST the nonce (no user id).
|
||||
/// * `callback` looks up the row, asserts `expiresAt > now()`, then
|
||||
/// DELETEs it in the same transaction — replay of the same nonce
|
||||
/// fails the second time because the row is gone.
|
||||
/// * A daily cleanup cron sweeps any abandoned rows whose
|
||||
/// `expiresAt` has passed (user closed the Withings tab without
|
||||
/// completing the handshake).
|
||||
model WithingsOAuthState {
|
||||
nonce String @id
|
||||
userId String @map("user_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([expiresAt])
|
||||
@@map("withings_oauth_states")
|
||||
}
|
||||
|
||||
// ─── Mood Entries (moodLog) ───────────────────────────────────
|
||||
|
||||
model MoodEntry {
|
||||
@@ -1333,11 +1391,12 @@ model MedicationComplianceRollup {
|
||||
// - consecutive failure counter (resets on success)
|
||||
//
|
||||
// `recordSyncSuccess` / `recordSyncFailure` helpers in
|
||||
// `src/lib/integrations/status.ts` are the only writers. The
|
||||
// `consecutiveFailures` counter feeds the admin-Telegram alerting
|
||||
// threshold (default 3). State `error_reauth` parks scheduled syncs
|
||||
// until the user reconnects (the sync entry-points consult this row
|
||||
// before doing any external work).
|
||||
// `src/lib/integrations/status.ts` are the only writers. The per-kind
|
||||
// `consecutiveFailuresByKind` JSON bucket feeds the admin-Telegram
|
||||
// alerting threshold (default 3) via `Math.max(...Object.values(...))`.
|
||||
// State `error_reauth` parks scheduled syncs until the user reconnects
|
||||
// (the sync entry-points consult this row before doing any external
|
||||
// work).
|
||||
//
|
||||
// `integration` values are bare strings ("withings", "moodlog") rather
|
||||
// than an enum so future integrations don't require a migration.
|
||||
@@ -1349,7 +1408,9 @@ model IntegrationStatus {
|
||||
lastSuccessAt DateTime? @map("last_success_at")
|
||||
lastAttemptAt DateTime? @map("last_attempt_at")
|
||||
lastError String? @map("last_error") // AES-256-GCM encrypted (may contain token-fragment metadata)
|
||||
consecutiveFailures Int @default(0) @map("consecutive_failures") // v1.4.44 will drop; per-kind counter below is the source of truth
|
||||
// v1.4.47 W1 — legacy single-column `consecutiveFailures` integer was
|
||||
// dropped (migration 0076). 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)
|
||||
alertedAt DateTime? @map("alerted_at") // last time admin Telegram alert fired (idempotency guard)
|
||||
@@ -1942,20 +2003,20 @@ model CoachUsage {
|
||||
// distinguish "ai_full" / "ai_insights_only" / future scopes without
|
||||
// requiring a schema migration per new tier.
|
||||
model ConsentReceipt {
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind String
|
||||
id String @id @default(cuid())
|
||||
userId String @map("user_id")
|
||||
kind String
|
||||
/// Raw consent artefact — either a base64-encoded PDF or a signed
|
||||
/// JWT. The server does not parse this payload; the audit trail is
|
||||
/// the storage itself plus the `signedAt` timestamp.
|
||||
artefact String @db.Text
|
||||
artefact String @db.Text
|
||||
/// Wall-clock the client recorded the user's tap. UTC ISO-8601.
|
||||
signedAt DateTime @map("signed_at")
|
||||
signedAt DateTime @map("signed_at")
|
||||
/// Optional revocation marker — set when a follow-up "revoke" row
|
||||
/// is minted so the latest-per-user lookup can shortcut to "no
|
||||
/// active consent" without diffing the kind discriminator.
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
|
||||
@@ -45,16 +45,9 @@ vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
}));
|
||||
|
||||
// v1.4.25 W21 Fix-K — `/api/admin/status` now reads the legacy-form
|
||||
// counter from the Withings webhook module. Mock it at module boundary
|
||||
// so the test fixture stays deterministic.
|
||||
vi.mock("@/app/api/withings/webhook/route", () => ({
|
||||
getLegacyFormTotal: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -178,12 +171,4 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
|
||||
it("surfaces the legacy Withings webhook counter on the counters block (Fix-K sec-M2)", async () => {
|
||||
vi.mocked(getLegacyFormTotal).mockReturnValue(7);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { counters: { withingsWebhookLegacyFormTotal: number } };
|
||||
};
|
||||
expect(body.data.counters.withingsWebhookLegacyFormTotal).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Build timestamp (set at build time via next.config)
|
||||
@@ -92,11 +91,5 @@ export const GET = apiHandler(async () => {
|
||||
webPush: webPushConfigured ? { configured: true } : null,
|
||||
bugReport: bugReportConfigured ? { configured: true } : null,
|
||||
},
|
||||
// v1.4.25 W21 Fix-K — in-memory counters that the release-gate can
|
||||
// read to confirm "legacy form usage trending toward zero" before
|
||||
// the v1.4.27 cut of the legacy Withings webhook route.
|
||||
counters: {
|
||||
withingsWebhookLegacyFormTotal: getLegacyFormTotal(),
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/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/disable-coach", {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/disable-coach", () => {
|
||||
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/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns false for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: false,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for an opted-out user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the column is missing (partial-deploy rollback)", async () => {
|
||||
// Defensive default — the API surface tolerates a `findUnique`
|
||||
// result that doesn't contain the column. Belt-and-braces with the
|
||||
// schema-level NOT NULL default; both layers keep the Coach
|
||||
// visible by default for users who never opted out.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/disable-coach"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/auth/me/disable-coach", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("flips the column on and writes the enable audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: false,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(true);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { disableCoach: true },
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.disable-coach.enable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: false,
|
||||
next: true,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("flips the column off and writes the disable audit row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: { disableCoach: boolean } };
|
||||
expect(env.data.disableCoach).toBe(false);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { disableCoach: false },
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.disable-coach.disable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: true,
|
||||
next: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a missing disableCoach field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ otherField: "ignored" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a non-boolean disableCoach field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: "true" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects malformed JSON with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const req = new Request("http://localhost/api/auth/me/disable-coach", {
|
||||
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("is idempotent when the column already holds the requested value", async () => {
|
||||
// Toggling a column from true→true (or false→false) still writes
|
||||
// through so the audit row exists. Matches the research-mode
|
||||
// route's "always write" posture.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
disableCoach: true,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PATCH as (r: Request) => Promise<Response>)(
|
||||
mkPatch({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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({ disableCoach: true }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* v1.4.47 W3 — per-user Coach opt-out endpoint.
|
||||
*
|
||||
* GET /api/auth/me/disable-coach — current flag.
|
||||
* PATCH /api/auth/me/disable-coach — body `{ disableCoach: boolean }`.
|
||||
* Flips the column and emits an
|
||||
* audit-log row mirroring the
|
||||
* Research-Mode endpoint pattern.
|
||||
*
|
||||
* 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
|
||||
* column-level documentation.
|
||||
*
|
||||
* Idempotent. The endpoint always returns the resolved next-state
|
||||
* shape so the client can hard-set the optimistic update without an
|
||||
* extra round-trip. Rate-limit is intentionally generous (60/min) —
|
||||
* the Switch is a one-tap affordance, but iOS clients may eventually
|
||||
* 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 { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } 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";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const PATCH_RATE_LIMIT = 60;
|
||||
const PATCH_WINDOW_MS = 60_000;
|
||||
|
||||
type DisableCoachResponse = {
|
||||
disableCoach: boolean;
|
||||
};
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.disable-coach.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { disableCoach: true },
|
||||
});
|
||||
const payload: DisableCoachResponse = {
|
||||
disableCoach: row?.disableCoach ?? false,
|
||||
};
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
|
||||
export const PATCH = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`disable-coach: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, "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"
|
||||
) {
|
||||
annotate({ action: { name: "auth.me.disable-coach.patch.invalid_shape" } });
|
||||
throw new HttpError(422, "disable-coach.body.invalid_shape");
|
||||
}
|
||||
|
||||
const next = (body as { disableCoach: boolean }).disableCoach;
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { disableCoach: true },
|
||||
});
|
||||
|
||||
// Idempotent — write even when the values match so the audit row
|
||||
// mirrors the API call. The DB write is cheap and the audit log is
|
||||
// the source of truth for "user toggled this".
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { disableCoach: next },
|
||||
});
|
||||
|
||||
await auditLog(
|
||||
next ? "user.disable-coach.enable" : "user.disable-coach.disable",
|
||||
{
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous?.disableCoach ?? false,
|
||||
next,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.disable-coach.patch" },
|
||||
meta: { disableCoach: next },
|
||||
});
|
||||
|
||||
const payload: DisableCoachResponse = { disableCoach: next };
|
||||
return apiSuccess(payload);
|
||||
});
|
||||
@@ -32,5 +32,11 @@ export const GET = apiHandler(async () => {
|
||||
gravatarUrl: user.email ? getGravatarUrl(user.email) : null,
|
||||
glucoseUnit: user.glucoseUnit ?? null,
|
||||
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
|
||||
// `user.disableCoach` BELOW the operator-level `flags.coach`
|
||||
// short-circuit; both gates must agree to paint the affordance.
|
||||
disableCoach: user.disableCoach ?? false,
|
||||
});
|
||||
});
|
||||
@@ -604,6 +604,13 @@ describe("GET /api/dashboard/summary", () => {
|
||||
// slot (e.g. the intake route or the reminder worker minted it
|
||||
// first), the dashboard route's backfill `createMany` must not
|
||||
// fire — the route simply re-reads + returns.
|
||||
//
|
||||
// v1.4.47 — pin the wall-clock so the projected scheduledFor and
|
||||
// the mocked existence row land on the same day regardless of
|
||||
// when the suite runs (previously hard-coded 2026-05-21 — broke
|
||||
// on 2026-05-22).
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-21T08:00:00.000Z"));
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const scheduledFor = new Date("2026-05-21T05:00:00.000Z");
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
@@ -646,6 +653,7 @@ describe("GET /api/dashboard/summary", () => {
|
||||
data: { compliance: { scheduledToday: number; takenToday: number } };
|
||||
};
|
||||
expect(body.data.compliance.scheduledToday).toBe(1);
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("computes intake compliance for today", async () => {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* Returns a snapshot per integration containing:
|
||||
* - the IntegrationStatus row (state, last-success, last-attempt,
|
||||
* decrypted last-error, consecutive failures, threshold)
|
||||
* decrypted last-error, per-kind failure buckets, threshold)
|
||||
* - integration-specific extras the UI already shows (Withings:
|
||||
* credentials configured, token expiry, OAuth-connected; moodLog:
|
||||
* credentials configured, enabled flag)
|
||||
@@ -92,7 +92,6 @@ interface IntegrationViewModel {
|
||||
lastSuccessAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
lastError: string | null;
|
||||
consecutiveFailures: number;
|
||||
}
|
||||
|
||||
interface WithingsExtras {
|
||||
|
||||
@@ -218,6 +218,12 @@ describe("GET /api/medications/intake", () => {
|
||||
});
|
||||
|
||||
it("does not double-mint when today's rows already exist", async () => {
|
||||
// v1.4.47 — pin the wall-clock so the projected scheduledFor and
|
||||
// the mocked existence row land on the same day regardless of
|
||||
// when the suite runs (previously hard-coded 2026-05-21 — broke
|
||||
// on 2026-05-22).
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-21T08:00:00.000Z"));
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const scheduledFor = new Date("2026-05-21T05:00:00.000Z");
|
||||
vi.mocked(prisma.medication.findMany).mockResolvedValue([
|
||||
@@ -256,6 +262,7 @@ describe("GET /api/medications/intake", () => {
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationIntakeEvent.createMany).not.toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("returns compliance buckets for the last N days", async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
|
||||
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 { NextRequest, NextResponse } from "next/server";
|
||||
@@ -12,6 +13,20 @@ import { timingSafeEqual } from "node:crypto";
|
||||
|
||||
/**
|
||||
* OAuth callback from Withings. Exchanges code for tokens and stores them.
|
||||
*
|
||||
* v1.4.47 W6 — `state` is now a fully-random 22-character base64url
|
||||
* nonce keyed against the `WithingsOAuthState` ledger, rather than the
|
||||
* legacy `${user.id}:${random16}` shape. The user identity bound to
|
||||
* the in-flight handshake is resolved via the ledger's `userId`
|
||||
* column (no longer parsed from the cookie value) and cross-checked
|
||||
* against the session — both must agree before the token exchange
|
||||
* runs. The row is consumed (deleted) on every exit branch so a
|
||||
* replay of the same nonce fails the second time.
|
||||
*
|
||||
* 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
|
||||
* captures.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -20,9 +35,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const code = searchParams.get("code");
|
||||
const state = searchParams.get("state");
|
||||
const storedState = request.cookies.get("withings_state")?.value;
|
||||
const storedState = request.cookies.get(WITHINGS_OAUTH_STATE_COOKIE)?.value;
|
||||
|
||||
// CSRF check (timing-safe comparison to prevent timing attacks)
|
||||
// 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.
|
||||
if (
|
||||
!state ||
|
||||
!storedState ||
|
||||
@@ -37,18 +54,42 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
);
|
||||
}
|
||||
|
||||
// Verify state contains current user's ID
|
||||
const [stateUserId] = state.split(":");
|
||||
if (stateUserId !== user.id) {
|
||||
// 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(() => {});
|
||||
}
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=user",
|
||||
"/settings/integrations?withings=error&reason=state",
|
||||
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",
|
||||
@@ -60,6 +101,9 @@ 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",
|
||||
@@ -112,16 +156,29 @@ 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",
|
||||
process.env.NEXT_PUBLIC_APP_URL!,
|
||||
),
|
||||
);
|
||||
response.cookies.delete("withings_state");
|
||||
response.cookies.delete(WITHINGS_OAUTH_STATE_COOKIE);
|
||||
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",
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
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 { getAuthorizationUrl } from "@/lib/withings/client";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
WITHINGS_OAUTH_STATE_COOKIE,
|
||||
WITHINGS_OAUTH_STATE_TTL_MS,
|
||||
mintWithingsOAuthStateNonce,
|
||||
} from "@/lib/withings/oauth-state";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Redirects the user to Withings OAuth authorization page.
|
||||
* State param = userId:random for CSRF protection.
|
||||
*
|
||||
* v1.4.47 W6 — state is now a fully-random 22-character base64url
|
||||
* nonce backed by a 10-minute `WithingsOAuthState` row, rather than
|
||||
* the legacy `${user.id}:${random16}` shape. The cookie carries JUST
|
||||
* the nonce (no user id), the OAuth state URL param carries JUST the
|
||||
* 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.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
@@ -22,17 +34,23 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
}
|
||||
|
||||
const stateNonce = randomBytes(16).toString("hex");
|
||||
const state = `${user.id}:${stateNonce}`;
|
||||
const nonce = mintWithingsOAuthStateNonce();
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
|
||||
},
|
||||
});
|
||||
|
||||
const url = getAuthorizationUrl(state, creds);
|
||||
const url = getAuthorizationUrl(nonce, creds);
|
||||
|
||||
const response = NextResponse.redirect(url);
|
||||
response.cookies.set("withings_state", state, {
|
||||
response.cookies.set(WITHINGS_OAUTH_STATE_COOKIE, nonce, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 600,
|
||||
maxAge: Math.floor(WITHINGS_OAUTH_STATE_TTL_MS / 1000),
|
||||
path: "/",
|
||||
});
|
||||
|
||||
|
||||
@@ -45,13 +45,7 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import {
|
||||
POST,
|
||||
GET,
|
||||
HEAD,
|
||||
getLegacyFormTotal,
|
||||
__resetLegacyFormTotalForTests,
|
||||
} from "../route";
|
||||
import { POST, GET, HEAD } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { syncUserActivity } from "@/lib/withings/sync-activity";
|
||||
@@ -174,7 +168,6 @@ describe("POST /api/withings/webhook", () => {
|
||||
});
|
||||
|
||||
it("legacy ?secret=… query path still authorises and emits a Wide Event warning with the migration URL (Fix-K sec-M2)", async () => {
|
||||
__resetLegacyFormTotalForTests();
|
||||
const addWarning = vi.fn();
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
@@ -198,25 +191,6 @@ describe("POST /api/withings/webhook", () => {
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/withings/webhook/[token]"),
|
||||
);
|
||||
// Fix-K sec-M2: in-memory counter increments on every legacy
|
||||
// ?secret=… call so the release-gate can watch usage trend to zero.
|
||||
expect(getLegacyFormTotal()).toBe(1);
|
||||
});
|
||||
|
||||
it("counter stays at zero when the secret arrives via the header (Fix-K sec-M2)", async () => {
|
||||
__resetLegacyFormTotalForTests();
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-h",
|
||||
withingsUserId: "wu-h",
|
||||
} as never);
|
||||
|
||||
await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-h" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(getLegacyFormTotal()).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts form-encoded payloads (Withings legacy delivery format)", async () => {
|
||||
|
||||
@@ -28,41 +28,17 @@ import {
|
||||
* while users re-subscribe (which also happens organically when they
|
||||
* reconnect for the new `user.activity` OAuth scope shipped in W5d).
|
||||
*
|
||||
* Removal target: v1.4.27 (after all live subscriptions have rotated).
|
||||
* Removal target: a future release once all live subscriptions have
|
||||
* rotated to the path-segment form. The warning is still emitted on the
|
||||
* legacy path so an operator watching the access log knows what to
|
||||
* migrate to.
|
||||
*
|
||||
* v1.4.25 W21 Fix-K — added an in-memory `withings.webhook.legacy_form_total`
|
||||
* counter the `/api/admin/status` endpoint surfaces so the release-gate
|
||||
* can read "legacy form usage trending toward zero" before the v1.4.27
|
||||
* cut. The warning text now includes the re-subscription URL so anyone
|
||||
* watching the access log knows what to migrate to.
|
||||
* v1.4.47 — dropped the in-memory `legacy_form_total` counter (was per-
|
||||
* process, useless on a multi-container deploy; the access-log warning
|
||||
* already covers the signal it provided).
|
||||
*/
|
||||
const MIGRATION_URL = "/api/withings/webhook/[token]" as const;
|
||||
|
||||
// In-memory counter — resets on every deploy / cold-start. That is the
|
||||
// intended granularity: we want to know "how many legacy-form requests
|
||||
// did this process serve since it came up", not a forever-running
|
||||
// cumulative metric (which would belong in Prisma).
|
||||
const counters = {
|
||||
legacy_form_total: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the legacy-form counter. Exported so `/api/admin/status` can
|
||||
* surface it without a backchannel.
|
||||
*/
|
||||
export function getLegacyFormTotal(): number {
|
||||
return counters.legacy_form_total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the legacy-form counter. Test-only utility; production code
|
||||
* does not call this. Documented so a future smoke test can wire up
|
||||
* a deterministic "counter was 0 before the call" assertion.
|
||||
*/
|
||||
export function __resetLegacyFormTotalForTests(): void {
|
||||
counters.legacy_form_total = 0;
|
||||
}
|
||||
|
||||
function hasValidWebhookSecret(
|
||||
request: NextRequest,
|
||||
): Promise<{ ok: boolean; via: "header" | "query" | "none" }> {
|
||||
@@ -89,7 +65,6 @@ async function checkAndWarn(
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (auth.via === "query") {
|
||||
counters.legacy_form_total += 1;
|
||||
getEvent()?.addWarning(
|
||||
`withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useAuth } from "@/hooks/use-auth";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -199,7 +200,7 @@ export default function BugReportPage() {
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="bug-desc">{t("bugreport.description")}</Label>
|
||||
<textarea
|
||||
<Textarea
|
||||
id="bug-desc"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
@@ -207,9 +208,6 @@ export default function BugReportPage() {
|
||||
minLength={10}
|
||||
maxLength={5000}
|
||||
rows={8}
|
||||
// text-base on mobile so iOS Safari doesn't zoom on
|
||||
// focus; text-sm on sm+ keeps the compact desktop look.
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -229,7 +227,11 @@ export default function BugReportPage() {
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-end pt-2">
|
||||
<Button type="submit" disabled={loading} aria-busy={loading || undefined}>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
aria-busy={loading || undefined}
|
||||
>
|
||||
{/*
|
||||
v1.4.33 IW9 — swap the icon on loading (Loader2 takes
|
||||
the same 16x16 slot as the Bug icon) so the button
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
MoreHorizontal,
|
||||
Loader2,
|
||||
@@ -279,48 +280,48 @@ export default function MedicationsPage() {
|
||||
className="sm:max-w-lg"
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<MedicationForm
|
||||
footerSlot={footerEl}
|
||||
initial={
|
||||
editingMed
|
||||
? {
|
||||
id: editingMed.id,
|
||||
name: editingMed.name,
|
||||
dose: editingMed.dose,
|
||||
category: editingMed.category,
|
||||
// v1.4.25 W4d — pass through the Prisma treatment-class
|
||||
// discriminator and the optional dosesPerUnit so the
|
||||
// form preselects the GLP-1 surfaces correctly when
|
||||
// editing an existing GLP-1 row.
|
||||
treatmentClass: editingMed.treatmentClass,
|
||||
dosesPerUnit: editingMed.dosesPerUnit ?? null,
|
||||
active: editingMed.active,
|
||||
notificationsEnabled: editingMed.notificationsEnabled,
|
||||
schedules: editingMed.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label ?? "",
|
||||
dose: s.dose ?? "",
|
||||
...parseScheduleRecurrence(s.daysOfWeek),
|
||||
})),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
editActions={
|
||||
editingMed
|
||||
? {
|
||||
onImportIntakes: () => setImportMedId(editingMed.id),
|
||||
onApiAccess: () =>
|
||||
setApiMed({
|
||||
id: editingMed.id,
|
||||
name: editingMed.name,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSuccess={closeDialog}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
<MedicationForm
|
||||
footerSlot={footerEl}
|
||||
initial={
|
||||
editingMed
|
||||
? {
|
||||
id: editingMed.id,
|
||||
name: editingMed.name,
|
||||
dose: editingMed.dose,
|
||||
category: editingMed.category,
|
||||
// v1.4.25 W4d — pass through the Prisma treatment-class
|
||||
// discriminator and the optional dosesPerUnit so the
|
||||
// form preselects the GLP-1 surfaces correctly when
|
||||
// editing an existing GLP-1 row.
|
||||
treatmentClass: editingMed.treatmentClass,
|
||||
dosesPerUnit: editingMed.dosesPerUnit ?? null,
|
||||
active: editingMed.active,
|
||||
notificationsEnabled: editingMed.notificationsEnabled,
|
||||
schedules: editingMed.schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
windowEnd: s.windowEnd,
|
||||
label: s.label ?? "",
|
||||
dose: s.dose ?? "",
|
||||
...parseScheduleRecurrence(s.daysOfWeek),
|
||||
})),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
editActions={
|
||||
editingMed
|
||||
? {
|
||||
onImportIntakes: () => setImportMedId(editingMed.id),
|
||||
onApiAccess: () =>
|
||||
setApiMed({
|
||||
id: editingMed.id,
|
||||
name: editingMed.name,
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onSuccess={closeDialog}
|
||||
onCancel={closeDialog}
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
</div>
|
||||
);
|
||||
@@ -465,14 +466,16 @@ function IntakeImportDialog({
|
||||
{"datum": "2026-02-14", "uhrzeit": "23:33:42", "zaehler": 524}
|
||||
]`}
|
||||
</pre>
|
||||
<textarea
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
placeholder={t("medications.importPaste")}
|
||||
rows={8}
|
||||
// text-base on mobile so iOS Safari doesn't zoom on
|
||||
// focus; text-sm on sm+ for the compact import textarea.
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 font-mono text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
|
||||
// JSON paste — disable sentence-case and spell-check so the
|
||||
// primitive defaults don't munge structured input.
|
||||
autoCapitalize="none"
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
{result && (
|
||||
<p
|
||||
|
||||
@@ -129,7 +129,7 @@ function getTargetSourceLink(target: TargetData): string | null {
|
||||
|
||||
export default function TargetsPage() {
|
||||
const { t } = useTranslations();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: queryKeys.insightsTargets(),
|
||||
@@ -162,7 +162,10 @@ export default function TargetsPage() {
|
||||
// below. The flag default is all-on for fresh installs and on any
|
||||
// fetch error, so the path stays open by default.
|
||||
const flags = useFeatureFlags();
|
||||
const coachEnabled = flags.coach;
|
||||
// v1.4.47 W3 — per-user "Hide Coach" toggle is a peer gate to the
|
||||
// operator's master flag. When either is off, the per-card CTAs +
|
||||
// the page-level <CoachDrawer> mount below both go dark.
|
||||
const coachEnabled = flags.coach && !user?.disableCoach;
|
||||
const aiEnabled = coachEnabled && chainStatus?.activeProvider != null;
|
||||
|
||||
// v1.4.25 W3e — Coach drawer state owned by the page. The per-card
|
||||
|
||||
@@ -28,6 +28,7 @@ import { EmptyState } from "@/components/ui/empty-state";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { formatDateTime } from "@/lib/format";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
@@ -86,7 +87,7 @@ export function FeedbackInboxSection() {
|
||||
instead of squashing the badge text below the legibility
|
||||
floor. Hide the scrollbar visually but keep the gesture
|
||||
working (matches the Insights tab-strip pattern). */}
|
||||
<div className="-mx-1 overflow-x-auto px-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="-mx-1 [scrollbar-width:none] overflow-x-auto px-1 [&::-webkit-scrollbar]:hidden">
|
||||
<TabsList>
|
||||
{FEEDBACK_STATUS_TABS.map((s) => (
|
||||
<TabsTrigger key={s} value={s}>
|
||||
@@ -428,100 +429,96 @@ function FeedbackDetailDialog({
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
|
||||
sequential beneath the ResponsiveSheet's h2 title. */}
|
||||
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h3>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h3>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
// text-base on mobile so iOS Safari doesn't zoom on
|
||||
// focus; text-sm on sm+ keeps the compact admin look.
|
||||
className="border-input bg-background text-foreground placeholder:text-muted-foreground focus-visible:ring-ring w-full rounded-md border px-3 py-2 text-base focus-visible:ring-2 focus-visible:outline-none sm:text-sm"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="bg-muted/40 rounded-md p-3 whitespace-pre-wrap">
|
||||
{item.description}
|
||||
</div>
|
||||
|
||||
{(url || locale || userAgent || appVersion) && (
|
||||
<div>
|
||||
{/* v1.4.33 IW9 — h4 -> h3 so the sheet outline stays
|
||||
sequential beneath the ResponsiveSheet's h2 title. */}
|
||||
<h3 className="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.metadataHeading")}
|
||||
</h3>
|
||||
<dl className="mt-2 grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 text-xs">
|
||||
{url && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUrl")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{url}</dd>
|
||||
</>
|
||||
)}
|
||||
{locale && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaLocale")}
|
||||
</dt>
|
||||
<dd className="font-mono">{locale}</dd>
|
||||
</>
|
||||
)}
|
||||
{appVersion && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaAppVersion")}
|
||||
</dt>
|
||||
<dd className="font-mono">{appVersion}</dd>
|
||||
</>
|
||||
)}
|
||||
{userAgent && (
|
||||
<>
|
||||
<dt className="text-muted-foreground">
|
||||
{t("admin.feedback.metaUserAgent")}
|
||||
</dt>
|
||||
<dd className="font-mono break-all">{userAgent}</dd>
|
||||
</>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.screenshotBase64 && (
|
||||
<div>
|
||||
<h3 className="text-muted-foreground mb-2 text-xs font-semibold tracking-wider uppercase">
|
||||
{t("admin.feedback.screenshotHeading")}
|
||||
</h3>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.screenshotBase64}
|
||||
alt="Screenshot"
|
||||
className="border-border max-h-72 rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="admin-feedback-note" className="text-xs">
|
||||
{t("admin.feedback.adminNote")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="admin-feedback-note"
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={3}
|
||||
maxLength={5000}
|
||||
placeholder={t("admin.feedback.adminNotePlaceholder")}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={update.isPending || note === (item.adminNote ?? "")}
|
||||
onClick={() => update.mutate({ adminNote: note || null })}
|
||||
>
|
||||
{update.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("admin.feedback.saveNote")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResponsiveSheet>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
|
||||
/**
|
||||
* Inline desktop Coach launch button.
|
||||
@@ -42,6 +43,7 @@ export function CoachLaunchButton({
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
|
||||
if (!launch) {
|
||||
// The button only makes sense beneath the provider. Render nothing
|
||||
@@ -50,6 +52,10 @@ export function CoachLaunchButton({
|
||||
}
|
||||
// v1.4.31 — operator can hide the Coach surface app-wide.
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out is a peer gate to the operator's
|
||||
// flag; either being off hides the pill entirely. See
|
||||
// `<LayoutCoachFab>` for the matching FAB gate.
|
||||
if (disableCoach) return null;
|
||||
|
||||
const accessibleLabel = label ?? t("insights.heroActionAskCoach");
|
||||
|
||||
@@ -64,10 +70,7 @@ export function CoachLaunchButton({
|
||||
size="sm"
|
||||
data-slot="coach-launch-inline"
|
||||
onClick={() => launch.askCoach(prefill ?? null)}
|
||||
className={cn(
|
||||
"hidden h-10 gap-2 self-end lg:inline-flex",
|
||||
className,
|
||||
)}
|
||||
className={cn("hidden h-10 gap-2 self-end lg:inline-flex", className)}
|
||||
>
|
||||
<Sparkles className="size-4" aria-hidden="true" />
|
||||
<span>{accessibleLabel}</span>
|
||||
|
||||
@@ -327,6 +327,20 @@ export function useSendCoachMessage(opts: UseSendCoachMessageOptions = {}) {
|
||||
errorCode: null,
|
||||
});
|
||||
|
||||
// v1.4.47 W8 — pre-check navigator.onLine so an airplane-mode
|
||||
// user gets the offline-specific copy immediately rather than
|
||||
// waiting for the fetch to fail with a generic network error.
|
||||
if (typeof navigator !== "undefined" && navigator.onLine === false) {
|
||||
setStreaming({
|
||||
content: "",
|
||||
metricSource: null,
|
||||
inProgress: false,
|
||||
messageId: null,
|
||||
errorCode: "coach.network",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch("/api/insights/chat", {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { formatRelativeTime } from "@/lib/i18n/relative-time";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SuggestedPrompts } from "./suggested-prompts";
|
||||
import {
|
||||
@@ -126,7 +127,14 @@ export function HeroStrip({
|
||||
// open the Coach drawer, so leaving them visible while the drawer
|
||||
// mount is suppressed would surface dead controls.
|
||||
const flags = useFeatureFlags();
|
||||
const coachEnabled = flags.coach;
|
||||
// v1.4.47 W3 — per-user "Hide Coach" toggle joins the operator's
|
||||
// master flag. Either-off resolves to `coachEnabled === false` so
|
||||
// the hero action button + suggested-prompts strip + the
|
||||
// `<HealthScoreCard onAskCoach />` prop all vanish together. The
|
||||
// hook is SSR-safe — returns `false` when the legacy snapshot
|
||||
// tests render the strip without a QueryClient.
|
||||
const disableCoach = useDisableCoach();
|
||||
const coachEnabled = flags.coach && !disableCoach;
|
||||
const greetingKey = resolveGreetingKey(now ?? new Date());
|
||||
const greetingBase = t(greetingKey);
|
||||
const greeting = userName ? `${greetingBase}, ${userName}` : greetingBase;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
|
||||
import { useChartTooltipActive } from "./use-chart-tooltip-active";
|
||||
|
||||
@@ -37,11 +38,18 @@ export function LayoutCoachFab() {
|
||||
const { t } = useTranslations();
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
const tooltipActive = useChartTooltipActive();
|
||||
if (!launch) return null;
|
||||
// v1.4.31 — operator can hide the Coach surface app-wide. The
|
||||
// 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
|
||||
// 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).
|
||||
if (disableCoach) return null;
|
||||
|
||||
const accessibleLabel = t("insights.heroActionAskCoach");
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import dynamic from "next/dynamic";
|
||||
|
||||
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
|
||||
/**
|
||||
* v1.4.31 — defer the Coach drawer subtree behind `next/dynamic` so
|
||||
@@ -36,10 +37,16 @@ const CoachDrawer = dynamic(
|
||||
export function LayoutCoachMount() {
|
||||
const launch = useCoachLaunch();
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
if (!launch) return null;
|
||||
// v1.4.31 — operator can hide the Coach drawer app-wide; suppress
|
||||
// the entire SSE mount when the flag is off.
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out short-circuits the drawer subtree
|
||||
// the same way the operator's master flag does. Keeps the SSE
|
||||
// chat reader + portal scaffolding out of the bundle for users
|
||||
// who never want the Coach mounted.
|
||||
if (disableCoach) return null;
|
||||
return (
|
||||
<CoachDrawer
|
||||
open={launch.open}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { Quote } from "lucide-react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useDisableCoach } from "@/hooks/use-disable-coach";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -56,7 +57,12 @@ export function SuggestedPrompts({
|
||||
// defence-in-depth so a future caller that mounts <SuggestedPrompts>
|
||||
// outside the hero band can never leak a Coach surface.
|
||||
const flags = useFeatureFlags();
|
||||
const disableCoach = useDisableCoach();
|
||||
if (!flags.coach) return null;
|
||||
// v1.4.47 W3 — per-user opt-out hides every Coach affordance,
|
||||
// including the suggested-prompts chip strip. Same posture as the
|
||||
// FAB / drawer / inline pill gate above.
|
||||
if (disableCoach) return null;
|
||||
const items =
|
||||
prompts ??
|
||||
DEFAULT_PROMPT_KEYS.map((key) => t(`insights.suggestedPrompts.${key}`));
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type {
|
||||
MedicationSideEffectCategory,
|
||||
@@ -144,14 +145,11 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
// from the wire payload. The server derives it from the entry
|
||||
// via `categoryForEntry`. Backwards-compatible with older
|
||||
// clients during the cut window (route accepts both shapes).
|
||||
const res = await fetch(
|
||||
`/api/medications/${medicationId}/side-effects`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
const res = await fetch(`/api/medications/${medicationId}/side-effects`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
@@ -255,7 +253,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
bodyPaddingY="py-2.5"
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
<span>{t("medications.sideEffects.loading")}</span>
|
||||
</div>
|
||||
@@ -269,7 +267,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
|
||||
{!isLoading && items.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-muted-foreground text-xs uppercase tracking-wide">
|
||||
<p className="text-muted-foreground text-xs tracking-wide uppercase">
|
||||
{t("medications.sideEffects.recentTitle")}
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
@@ -333,9 +331,7 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
onClick={() => {
|
||||
if (
|
||||
typeof window === "undefined" ||
|
||||
window.confirm(
|
||||
t("medications.sideEffects.deleteConfirm"),
|
||||
)
|
||||
window.confirm(t("medications.sideEffects.deleteConfirm"))
|
||||
) {
|
||||
deleteMutation.mutate(row.id);
|
||||
}
|
||||
@@ -356,165 +352,161 @@ export function SideEffectsSection({ medicationId }: SideEffectsSectionProps) {
|
||||
title={t("medications.sideEffects.sheetTitle")}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
aria-label={t("medications.sideEffects.sheetTitle")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-category">
|
||||
{t("medications.sideEffects.categoryLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(next) => {
|
||||
setCategory(next as MedicationSideEffectCategory);
|
||||
// Reset entry — the previously-selected entry probably
|
||||
// doesn't belong to the new category.
|
||||
setEntry(null);
|
||||
}}
|
||||
<form
|
||||
id={formId}
|
||||
onSubmit={handleSubmit}
|
||||
className="space-y-4"
|
||||
aria-label={t("medications.sideEffects.sheetTitle")}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-category">
|
||||
{t("medications.sideEffects.categoryLabel")}
|
||||
</Label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(next) => {
|
||||
setCategory(next as MedicationSideEffectCategory);
|
||||
// Reset entry — the previously-selected entry probably
|
||||
// doesn't belong to the new category.
|
||||
setEntry(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="side-effect-category"
|
||||
className="w-full"
|
||||
data-slot="side-effect-category-trigger"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="side-effect-category"
|
||||
className="w-full"
|
||||
data-slot="side-effect-category-trigger"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{t(
|
||||
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
|
||||
)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.entryLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.entryLabel")}
|
||||
>
|
||||
{filteredEntries.map((candidate) => {
|
||||
const selected = candidate === entry;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={candidate}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setEntry(candidate)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.severityLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.severityLabel")}
|
||||
>
|
||||
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
|
||||
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
|
||||
const selected = severity === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={label}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setSeverity(value)}
|
||||
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden className="block text-sm">
|
||||
{value}
|
||||
</span>
|
||||
<span className="block text-[10px] opacity-80">
|
||||
{t(`medications.sideEffects.severity.${label}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-notes">
|
||||
{t("medications.sideEffects.notesLabel")}
|
||||
</Label>
|
||||
<textarea
|
||||
id="side-effect-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
|
||||
placeholder={t("medications.sideEffects.notesPlaceholder")}
|
||||
rows={3}
|
||||
maxLength={NOTES_MAX}
|
||||
enterKeyHint="done"
|
||||
autoCapitalize="sentences"
|
||||
autoComplete="off"
|
||||
// text-base on mobile so iOS Safari doesn't
|
||||
// zoom-on-focus; text-sm on sm+ keeps the compact
|
||||
// notes-row visual.
|
||||
className="border-input bg-background text-foreground w-full rounded-md border px-2 py-1.5 text-base sm:text-sm"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-[10px]">
|
||||
{notes.length} / {NOTES_MAX}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
</form>
|
||||
{footerEl
|
||||
? createPortal(
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("medications.sideEffects.cancelCta")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SIDE_EFFECT_CATEGORY_ORDER.map((c) => (
|
||||
<SelectItem key={c} value={c}>
|
||||
{t(
|
||||
`medications.sideEffects.categories.${categoryI18nKey(c)}`,
|
||||
)}
|
||||
{t("medications.sideEffects.submitCta")}
|
||||
</Button>
|
||||
</div>,
|
||||
footerEl,
|
||||
)
|
||||
: null}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.entryLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.entryLabel")}
|
||||
>
|
||||
{filteredEntries.map((candidate) => {
|
||||
const selected = candidate === entry;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={candidate}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setEntry(candidate)}
|
||||
className={`rounded-full border px-2.5 py-1 text-xs transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
`medications.sideEffects.entries.${entryI18nKey(candidate)}`,
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t("medications.sideEffects.severityLabel")}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="radiogroup"
|
||||
aria-label={t("medications.sideEffects.severityLabel")}
|
||||
>
|
||||
{SIDE_EFFECT_SEVERITY_LADDER.map((label, idx) => {
|
||||
const value = (idx + 1) as 1 | 2 | 3 | 4 | 5;
|
||||
const selected = severity === value;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={label}
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => setSeverity(value)}
|
||||
className={`min-w-[2.5rem] rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
||||
selected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-border bg-background text-foreground hover:bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span aria-hidden className="block text-sm">
|
||||
{value}
|
||||
</span>
|
||||
<span className="block text-[10px] opacity-80">
|
||||
{t(`medications.sideEffects.severity.${label}`)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="side-effect-notes">
|
||||
{t("medications.sideEffects.notesLabel")}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="side-effect-notes"
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value.slice(0, NOTES_MAX))}
|
||||
placeholder={t("medications.sideEffects.notesPlaceholder")}
|
||||
rows={3}
|
||||
maxLength={NOTES_MAX}
|
||||
enterKeyHint="done"
|
||||
// Compact notes-row padding override; primitive's
|
||||
// px-3 py-2 is too tall inside the side-effect modal.
|
||||
className="px-2 py-1.5"
|
||||
/>
|
||||
<p className="text-muted-foreground text-right text-[10px]">
|
||||
{notes.length} / {NOTES_MAX}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{formError && (
|
||||
<p className="text-destructive text-sm" role="alert">
|
||||
{formError}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
{footerEl
|
||||
? createPortal(
|
||||
<div className="flex w-full flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("medications.sideEffects.cancelCta")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form={formId}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending && (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("medications.sideEffects.submitCta")}
|
||||
</Button>
|
||||
</div>,
|
||||
footerEl,
|
||||
)
|
||||
: null}
|
||||
</ResponsiveSheet>
|
||||
</MedicationDetailSection>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* v1.4.47 W5 — onboarding chain gate.
|
||||
*
|
||||
* `<WelcomeCarousel>` (gated on `users.onboardingCompletedAt`) and
|
||||
* `<TourLauncher>` (gated on `users.onboardingTourCompleted`) used to
|
||||
* both auto-launch when null. A brand-new user finished the carousel
|
||||
* and was immediately greeted by the spotlight tour — ~90 seconds of
|
||||
* forced onboarding before they could touch the dashboard.
|
||||
*
|
||||
* The fix turns the tour into "the second-visit thing": auto-launch
|
||||
* is suppressed until `onboardingCompletedAt + 24 h`. This file pins
|
||||
* the pure decision helper so a future refactor doesn't silently
|
||||
* regress the gate. The "manual restart from Settings" path bypasses
|
||||
* the gate entirely and lives in `<TourLauncher>`'s
|
||||
* `healthlog:tour-restart` window event listener — exercised by the
|
||||
* sections SSR smoke test (`settings/__tests__/sections.test.tsx`) +
|
||||
* the e2e suite, not by this file.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
setTourForceLaunch,
|
||||
shouldAutoLaunchTour,
|
||||
TOUR_AUTOLAUNCH_DELAY_MS,
|
||||
tourForceLaunchKey,
|
||||
} from "../tour-launcher";
|
||||
|
||||
const NOW = Date.parse("2026-05-21T12:00:00Z");
|
||||
const ONE_HOUR_MS = 60 * 60 * 1000;
|
||||
|
||||
describe("shouldAutoLaunchTour() — v1.4.47 W5 chain-gate", () => {
|
||||
it("returns false when the user already completed (or dismissed) the tour", () => {
|
||||
// Hard veto independent of timing — a user who already saw the
|
||||
// tour should never see it again from the auto-launch path even
|
||||
// if their wizard completion is years in the past.
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: true,
|
||||
onboardingCompletedAt: new Date(NOW - 365 * 24 * ONE_HOUR_MS)
|
||||
.toISOString(),
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for brand-new users who have not finished the wizard yet", () => {
|
||||
// (a) of the W5 acceptance: the tour does NOT mount in the same
|
||||
// session as carousel completion. Concretely: the wizard hasn't
|
||||
// POSTed step 4 yet (`onboardingCompletedAt` still null), so the
|
||||
// 24 h clock has not started — auto-launch is suppressed.
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: null,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the wizard finished less than 24 h ago", () => {
|
||||
// Same-session and same-day re-visits both fall here. The
|
||||
// welcome carousel owns the post-wizard first impression; the
|
||||
// tour stays out of the way.
|
||||
const oneHourAfterWizard = new Date(NOW - ONE_HOUR_MS).toISOString();
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: oneHourAfterWizard,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
// Boundary just inside the 24 h window — still suppressed.
|
||||
const justUnder24h = new Date(
|
||||
NOW - TOUR_AUTOLAUNCH_DELAY_MS + 1,
|
||||
).toISOString();
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: justUnder24h,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true on a synthetic mount ≥ 24 h after wizard completion", () => {
|
||||
// (b) of the W5 acceptance: the tour DOES mount on a synthetic
|
||||
// ≥ 24 h-later mount. Two cases bracket the boundary — exactly
|
||||
// at the 24 h mark and a day later.
|
||||
const exactly24hAfter = new Date(
|
||||
NOW - TOUR_AUTOLAUNCH_DELAY_MS,
|
||||
).toISOString();
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: exactly24hAfter,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
const twoDaysAfter = new Date(
|
||||
NOW - 2 * TOUR_AUTOLAUNCH_DELAY_MS,
|
||||
).toISOString();
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: twoDaysAfter,
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when the timestamp string cannot be parsed", () => {
|
||||
// Defensive: a malformed payload (e.g. accidental locale-string
|
||||
// serialisation upstream) should never auto-launch the tour and
|
||||
// surprise a brand-new user. Treat unparseable as "no completion".
|
||||
expect(
|
||||
shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: false,
|
||||
onboardingCompletedAt: "not-a-real-iso-timestamp",
|
||||
nowMs: NOW,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a 24 h delay constant (locked exact value)", () => {
|
||||
// Pin the constant so a future refactor that tries to drop the
|
||||
// delay (e.g. "let's just enable it after one hour") has to
|
||||
// confront this test. The number lives in the source and is
|
||||
// re-exported for tests + for any future runtime introspection.
|
||||
expect(TOUR_AUTOLAUNCH_DELAY_MS).toBe(24 * 60 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setTourForceLaunch() — v1.4.47 W5 manual replay bypass", () => {
|
||||
// (c) of the W5 acceptance: the manual "launch tour" trigger still
|
||||
// works. The launcher's render-phase gate is exercised by
|
||||
// shouldAutoLaunchTour() above; the *bypass* is a sessionStorage
|
||||
// marker the Settings buttons drop and the launcher reads-and-
|
||||
// clears on its next mount. Vitest runs in the Node environment by
|
||||
// default — stub a minimal window so the helper's window-guard +
|
||||
// try/catch wrappers can be exercised end-to-end.
|
||||
let storage: Map<string, string>;
|
||||
const originalWindow = (globalThis as { window?: unknown }).window;
|
||||
|
||||
beforeEach(() => {
|
||||
storage = new Map<string, string>();
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
sessionStorage: {
|
||||
getItem: (key: string) => storage.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
storage.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
storage.delete(key);
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow === undefined) {
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
} else {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
});
|
||||
|
||||
it("writes the per-user force-launch marker into sessionStorage", () => {
|
||||
// The Settings → About + Settings → Account "Replay tour" buttons
|
||||
// call this exact helper after the API round-trip succeeds. The
|
||||
// launcher reads-and-clears the marker on its next dashboard
|
||||
// mount, bypassing the 24 h auto-launch gate so a same-day user
|
||||
// who explicitly asked for the tour actually gets it.
|
||||
setTourForceLaunch("u_replay_demo");
|
||||
expect(storage.get(tourForceLaunchKey("u_replay_demo"))).toBe("1");
|
||||
});
|
||||
|
||||
it("scopes the marker per user (two users do not bleed into each other)", () => {
|
||||
setTourForceLaunch("u_alice");
|
||||
setTourForceLaunch("u_bob");
|
||||
expect(storage.get(tourForceLaunchKey("u_alice"))).toBe("1");
|
||||
expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
|
||||
// The two keys are independent slots — clearing one (which the
|
||||
// launcher does on mount) must not affect the other.
|
||||
storage.delete(tourForceLaunchKey("u_alice"));
|
||||
expect(storage.get(tourForceLaunchKey("u_bob"))).toBe("1");
|
||||
});
|
||||
|
||||
it("does not throw when window is undefined (SSR safety)", () => {
|
||||
// The launcher itself is "use client" but the helper is exported
|
||||
// from the same module and could be imported by a server component
|
||||
// in the future. The window-guard inside the helper has to keep
|
||||
// SSR happy — verify it stays a silent no-op.
|
||||
delete (globalThis as { window?: unknown }).window;
|
||||
expect(() => setTourForceLaunch("u_x")).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -5,15 +5,26 @@
|
||||
* browser) inherited the first user's "tour dismissed for this
|
||||
* session" flag and the tour silently no-op'd for the second user.
|
||||
*
|
||||
* The two key-builders are exported so this lock-test pins the wire
|
||||
* The key-builders are exported so this lock-test pins the wire
|
||||
* format. Changing them is a soft compatibility break — every
|
||||
* already-rendered tab would re-fire the tour for the user once,
|
||||
* because the un-prefixed legacy keys are never read again. We
|
||||
* accept that one-off tax in exchange for the multi-tenant
|
||||
* correctness.
|
||||
*
|
||||
* v1.4.47 W5 adds a third key, `tourForceLaunchKey`, that carries
|
||||
* the "user just clicked Replay in Settings → About/Account" intent
|
||||
* across the navigation back to the dashboard. The launcher reads
|
||||
* and clears it on its next mount, bypassing the new 24 h
|
||||
* auto-launch gate so a first-day user who *wants* the tour
|
||||
* actually gets it.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { tourReferrerKey, tourSessionDismissedKey } from "../tour-launcher";
|
||||
import {
|
||||
tourForceLaunchKey,
|
||||
tourReferrerKey,
|
||||
tourSessionDismissedKey,
|
||||
} from "../tour-launcher";
|
||||
|
||||
describe("tour-launcher sessionStorage key scoping", () => {
|
||||
it("scopes the dismiss key by user id", () => {
|
||||
@@ -26,11 +37,20 @@ describe("tour-launcher sessionStorage key scoping", () => {
|
||||
expect(tourReferrerKey("u_abc")).toBe("healthlog-tour-referrer:u_abc");
|
||||
});
|
||||
|
||||
it("scopes the force-launch key by user id (v1.4.47 W5)", () => {
|
||||
expect(tourForceLaunchKey("u_abc")).toBe(
|
||||
"healthlog-tour-force-launch:u_abc",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns distinct keys for distinct users", () => {
|
||||
expect(tourSessionDismissedKey("u_alice")).not.toBe(
|
||||
tourSessionDismissedKey("u_bob"),
|
||||
);
|
||||
expect(tourReferrerKey("u_alice")).not.toBe(tourReferrerKey("u_bob"));
|
||||
expect(tourForceLaunchKey("u_alice")).not.toBe(
|
||||
tourForceLaunchKey("u_bob"),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not collide with the legacy un-scoped keys", () => {
|
||||
@@ -38,5 +58,20 @@ describe("tour-launcher sessionStorage key scoping", () => {
|
||||
"healthlog-tour-session-dismissed",
|
||||
);
|
||||
expect(tourReferrerKey("u_x")).not.toBe("healthlog-tour-referrer");
|
||||
expect(tourForceLaunchKey("u_x")).not.toBe("healthlog-tour-force-launch");
|
||||
});
|
||||
|
||||
it("uses three distinct namespaces so the keys can never alias each other", () => {
|
||||
// Belt-and-braces: if a future refactor accidentally collapses
|
||||
// two key builders onto the same prefix, the launcher's gating
|
||||
// logic would conflate "session-dismissed" with "force-launch"
|
||||
// and produce a one-shot tour-loop on every dashboard mount.
|
||||
const userId = "u_test";
|
||||
const keys = new Set([
|
||||
tourSessionDismissedKey(userId),
|
||||
tourReferrerKey(userId),
|
||||
tourForceLaunchKey(userId),
|
||||
]);
|
||||
expect(keys.size).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
* Decides *when* to start the spotlight tour. Mounted on the
|
||||
* dashboard; opens the tour exactly once for new users and never
|
||||
* again unless they explicitly request a replay from
|
||||
* Settings → Account.
|
||||
* Settings → Account or Settings → About.
|
||||
*
|
||||
* Gating rules:
|
||||
*
|
||||
@@ -28,13 +28,23 @@
|
||||
* races with an in-flight `/api/auth/me` refetch, the cached
|
||||
* response could re-open the tour. The localStorage guard
|
||||
* blocks that for the rest of the session.
|
||||
* 5. v1.4.47 W5 — make the tour "the second-visit thing". The
|
||||
* welcome carousel runs immediately after the wizard; piling
|
||||
* the tour on top of that consumes ~90 s of forced onboarding
|
||||
* before the user gets to touch the dashboard. The tour now
|
||||
* auto-launches only after the wizard has been finished at
|
||||
* least 24 h ago. Brand-new users (`onboardingCompletedAt ==
|
||||
* null`) never see the auto-launch — they can still trigger
|
||||
* the tour manually from Settings → About or Settings →
|
||||
* Account.
|
||||
*
|
||||
* Replay flow: the Settings → Account "Restart" button posts
|
||||
* `{ completed: false }` to /api/onboarding/tour, then dispatches a
|
||||
* Replay flow: the Settings → Account "Restart" button (and the
|
||||
* Settings → About "Replay" button added in v1.4.47 W5) POST
|
||||
* `{ completed: false }` to /api/onboarding/tour, then dispatch a
|
||||
* `healthlog:tour-restart` window event. This launcher listens for
|
||||
* that event and force-opens the tour without reading the flag —
|
||||
* convenient because invalidating the auth cache + waiting for the
|
||||
* round-trip would feel laggy.
|
||||
* that event and force-opens the tour ignoring the auto-launch gate
|
||||
* (24 h delay, session-dismissed flag, post-wizard grace) so the
|
||||
* manual replay always works.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -45,6 +55,51 @@ import { OnboardingTour } from "./tour";
|
||||
|
||||
const POST_WIZARD_GRACE_MS = 1500;
|
||||
|
||||
/**
|
||||
* v1.4.47 W5 — auto-launch only kicks in 24 h after the wizard
|
||||
* finished. The first visit is owned by the WelcomeCarousel; the
|
||||
* tour becomes "the second-visit thing".
|
||||
*/
|
||||
export const TOUR_AUTOLAUNCH_DELAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Pure decision helper exported for unit tests. Given the auth-shape
|
||||
* timing inputs the launcher cares about, return whether the tour
|
||||
* should auto-launch right now.
|
||||
*
|
||||
* Inputs:
|
||||
* - `onboardingTourCompleted` — server flag; true means the user
|
||||
* already finished or dismissed the tour. Hard veto.
|
||||
* - `onboardingCompletedAt` — ISO timestamp of wizard completion
|
||||
* (or `null` for users who never finished the wizard). The
|
||||
* 24 h gate counts from here.
|
||||
* - `nowMs` — caller-supplied "current time" so tests can pin a
|
||||
* synthetic clock without monkey-patching `Date.now()`.
|
||||
*
|
||||
* Returns:
|
||||
* - `false` when any veto applies (tour already done; no wizard
|
||||
* completion yet; wizard finished <24 h ago).
|
||||
* - `true` when the user is eligible and the 24 h delay has passed.
|
||||
*
|
||||
* The function does NOT consult sessionStorage — that's a separate
|
||||
* concern (per-tab dismiss + post-wizard grace) handled by the
|
||||
* launcher around it. Pure for testing.
|
||||
*/
|
||||
export function shouldAutoLaunchTour(args: {
|
||||
onboardingTourCompleted: boolean;
|
||||
onboardingCompletedAt: string | null;
|
||||
nowMs: number;
|
||||
}): boolean {
|
||||
if (args.onboardingTourCompleted) return false;
|
||||
if (args.onboardingCompletedAt == null) return false;
|
||||
const completedMs = Date.parse(args.onboardingCompletedAt);
|
||||
// Date.parse returns NaN for unparseable inputs. Treat the
|
||||
// unparseable case as "no completion yet" so a malformed payload
|
||||
// never auto-launches the tour into an unsuspecting brand-new user.
|
||||
if (Number.isNaN(completedMs)) return false;
|
||||
return args.nowMs - completedMs >= TOUR_AUTOLAUNCH_DELAY_MS;
|
||||
}
|
||||
|
||||
// v1.4.15 H4 — sessionStorage keys are now scoped by user id so an
|
||||
// admin impersonating a second user does not inherit the first user's
|
||||
// "tour dismissed for this session" state. The previous global keys
|
||||
@@ -64,6 +119,48 @@ export function tourReferrerKey(userId: string): string {
|
||||
return `healthlog-tour-referrer:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W5 — sessionStorage key used by the Settings → About
|
||||
* "Replay the tour" + Settings → Account "Restart onboarding tour"
|
||||
* buttons to bypass the 24 h auto-launch gate on the next dashboard
|
||||
* mount. Without this, a first-day user who manually clicks the
|
||||
* replay button would still hit the 24 h block after navigating to
|
||||
* the dashboard (because the launcher mounts fresh and re-evaluates
|
||||
* the auto-launch gate). Exported for the unit-test suite + so the
|
||||
* key construction stays pinned across refactors.
|
||||
*/
|
||||
export function tourForceLaunchKey(userId: string): string {
|
||||
return `healthlog-tour-force-launch:${userId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the force-launch marker. Called by both Settings → Account
|
||||
* and Settings → About when the user explicitly requests the tour.
|
||||
* Exported so the Settings buttons can call it without re-implementing
|
||||
* the sessionStorage dance (and so the test suite can stub it).
|
||||
*/
|
||||
export function setTourForceLaunch(userId: string): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(tourForceLaunchKey(userId), "1");
|
||||
} catch {
|
||||
/* ignore — sandboxed iframes etc. */
|
||||
}
|
||||
}
|
||||
|
||||
function readAndClearForceLaunch(userId: string): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
try {
|
||||
const key = tourForceLaunchKey(userId);
|
||||
const value = window.sessionStorage.getItem(key);
|
||||
if (value !== "1") return false;
|
||||
window.sessionStorage.removeItem(key);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user just navigated here from `/onboarding`. Read
|
||||
* once at mount via a `useState` lazy initialiser (which runs in a
|
||||
@@ -151,6 +248,18 @@ export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
userId: string;
|
||||
flag: boolean;
|
||||
} | null>(null);
|
||||
// v1.4.47 W5 — clock reading for the 24 h auto-launch gate. The
|
||||
// useState lazy-initialiser runs once at mount; subsequent re-renders
|
||||
// reuse the same value. This keeps the render path pure (React's
|
||||
// purity linter rejects a bare `Date.now()` reference in render) and
|
||||
// matches the established pattern in `personal-record-badge.tsx`:
|
||||
// capture the clock at a stable boundary, then derive freshness from
|
||||
// it. The launcher only consults this value once per (user, flag)
|
||||
// tuple via the `decidedFor` guard, so a mount that takes ten
|
||||
// minutes to reach the decision still uses a representative timestamp
|
||||
// (and a user who crosses the 24 h boundary mid-session will see the
|
||||
// tour on their next page-load — acceptable for a one-shot welcome).
|
||||
const [mountedAtMs] = useState(() => Date.now());
|
||||
|
||||
const inputsReady = !isLoading && isAuthenticated && !!user && ready;
|
||||
// The set-state-in-render pattern (mirrors `account-section.tsx`'s
|
||||
@@ -169,7 +278,28 @@ export function TourLauncher({ ready }: TourLauncherProps) {
|
||||
// sessionStorage reads happen via per-user keyed helpers, so a
|
||||
// browser shared by two HealthLog users (admin impersonation,
|
||||
// family laptop, etc.) keeps each user's tour state independent.
|
||||
if (user.onboardingTourCompleted) {
|
||||
//
|
||||
// v1.4.47 W5 — the 24 h auto-launch gate. The mount-time clock is
|
||||
// captured into `mountedAtMs` via a useState lazy initialiser so
|
||||
// the render-phase decision stays pure (no Date.now() in the
|
||||
// render body). The `decidedFor` tuple above further guarantees
|
||||
// this branch runs at most once per (user, flag) transition.
|
||||
//
|
||||
// Force-launch bypass: when the user explicitly requests the tour
|
||||
// from Settings → About / Settings → Account, those buttons set a
|
||||
// per-user sessionStorage marker via `setTourForceLaunch()`. The
|
||||
// launcher reads-and-clears the marker here so the tour launches
|
||||
// on the very next dashboard mount — independent of the 24 h gate,
|
||||
// the session-dismissed flag, and the post-wizard grace. The
|
||||
// marker survives navigation but not a page reload, which matches
|
||||
// user expectation ("I clicked replay, now show me the tour").
|
||||
if (readAndClearForceLaunch(user.id)) {
|
||||
setShowTour(true);
|
||||
} else if (!shouldAutoLaunchTour({
|
||||
onboardingTourCompleted: user.onboardingTourCompleted,
|
||||
onboardingCompletedAt: user.onboardingCompletedAt,
|
||||
nowMs: mountedAtMs,
|
||||
})) {
|
||||
setShowTour(false);
|
||||
} else if (readSessionDismissed(user.id)) {
|
||||
setShowTour(false);
|
||||
|
||||
@@ -195,4 +195,28 @@ describe("<AiSection> — dropdown-driven UX (B2)", () => {
|
||||
expect(html).toContain("Aktiver Provider");
|
||||
expect(html).toContain("Primärer Provider");
|
||||
});
|
||||
|
||||
// v1.4.47 W3 — per-user "Hide Coach" toggle. The card lives at the
|
||||
// bottom of the AI section so privacy-leaning users never scroll
|
||||
// past the provider configuration to find it.
|
||||
it("mounts the Hide-Coach toggle card", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-disable-coach-card"');
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
// The localised label + helper line are both surfaced — proves
|
||||
// the i18n keys resolve and the description string ships with the
|
||||
// bilingual copy required by the QoL audit (M2).
|
||||
expect(html).toContain("Hide Coach");
|
||||
expect(html).toContain("Hides the Coach button and drawer everywhere.");
|
||||
});
|
||||
|
||||
it("renders the German Hide-Coach copy", () => {
|
||||
activeProviderQuery = "codex";
|
||||
const html = render("de");
|
||||
expect(html).toContain("Coach ausblenden");
|
||||
expect(html).toContain(
|
||||
"Versteckt den Coach-Button und das Coach-Panel auf allen Seiten.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,12 @@ import { DEFAULT_DASHBOARD_LAYOUT } from "@/lib/dashboard-layout";
|
||||
* tile, distinct from the existing chart switch. SSR smoke tests are
|
||||
* sufficient here; full state-mutation testing would require a DOM
|
||||
* runtime which the rest of the settings suite doesn't pull in.
|
||||
*
|
||||
* v1.4.47 W4 — drag-and-drop reorder via @dnd-kit. The SSR markup picks
|
||||
* up the new drag handle, the shared describedby hint paragraph, and
|
||||
* keeps the legacy arrow buttons + switches. The reorder contract is
|
||||
* exercised via the exported `reorderWidgets` helper so we pin the
|
||||
* mutation payload shape without a DOM runtime.
|
||||
*/
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
@@ -30,7 +36,10 @@ vi.mock("sonner", () => ({
|
||||
}));
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { DashboardLayoutSection } from "../dashboard-layout-section";
|
||||
import {
|
||||
DashboardLayoutSection,
|
||||
reorderWidgets,
|
||||
} from "../dashboard-layout-section";
|
||||
|
||||
function render(node: React.ReactElement, locale: "en" | "de" = "en") {
|
||||
return renderToStaticMarkup(
|
||||
@@ -70,3 +79,111 @@ describe("<DashboardLayoutSection> — tile + chart split", () => {
|
||||
expect(chartSwitches).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — drag-and-drop reorder surface.
|
||||
*
|
||||
* The audit (v1.4.43 QoL M1) asked for drag-to-reorder while keeping the
|
||||
* arrow-button keyboard fallback. Tests pin (a) the visual surface
|
||||
* (drag handle + describedby hint), (b) the keyboard fallback survived,
|
||||
* (c) the reorder contract — same `widgets[]` shape with `order: 0..n-1`
|
||||
* the existing PUT already accepts.
|
||||
*/
|
||||
describe("<DashboardLayoutSection> — drag-and-drop reorder", () => {
|
||||
it("paints a drag handle for every widget row", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
const handles = html.match(/data-slot="widget-drag-handle"/g) ?? [];
|
||||
expect(handles).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
});
|
||||
|
||||
it("each drag handle has an aria-describedby pointing to a shared hint", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// Split the markup into per-handle button fragments so we don't need
|
||||
// to care about attribute ordering inside the rendered <button>.
|
||||
const handleButtons =
|
||||
html.match(/<button[^>]*data-slot="widget-drag-handle"[^>]*>/g) ?? [];
|
||||
expect(handleButtons).toHaveLength(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
// Every handle declares aria-describedby and they all share the
|
||||
// same id (one hint paragraph below the list — single source of
|
||||
// truth for screen readers).
|
||||
const describedByIds = handleButtons.map((button) => {
|
||||
const m = button.match(/aria-describedby="([^"]+)"/);
|
||||
expect(m).not.toBeNull();
|
||||
return m![1];
|
||||
});
|
||||
const unique = new Set(describedByIds);
|
||||
expect(unique.size).toBe(1);
|
||||
const hintId = describedByIds[0];
|
||||
// The matching hint paragraph is rendered exactly once.
|
||||
expect(html).toMatch(new RegExp(`id="${hintId}"`));
|
||||
});
|
||||
|
||||
it("keeps the arrow buttons present after the drag handle lands (a11y fallback)", () => {
|
||||
const html = render(<DashboardLayoutSection id="dashboard-layout" />);
|
||||
// Move up + Move down translation keys resolve to the English
|
||||
// strings — counted once per row.
|
||||
const moveUpCount = (html.match(/aria-label="Move up"/g) ?? []).length;
|
||||
const moveDownCount = (html.match(/aria-label="Move down"/g) ?? []).length;
|
||||
expect(moveUpCount).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
expect(moveDownCount).toBe(DEFAULT_DASHBOARD_LAYOUT.widgets.length);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("reorderWidgets — pure mutation contract", () => {
|
||||
// Synthetic small list so the assertions are readable; the helper is
|
||||
// shape-only and doesn't care which ids are dashboard widgets.
|
||||
const initial = [
|
||||
{ id: "a", order: 0 },
|
||||
{ id: "b", order: 1 },
|
||||
{ id: "c", order: 2 },
|
||||
{ id: "d", order: 3 },
|
||||
];
|
||||
|
||||
it("moves an item down and rewrites order to 0..n-1", () => {
|
||||
const out = reorderWidgets(initial, "a", "c");
|
||||
expect(out.map((w) => w.id)).toEqual(["b", "c", "a", "d"]);
|
||||
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("moves an item up and rewrites order to 0..n-1", () => {
|
||||
const out = reorderWidgets(initial, "d", "a");
|
||||
expect(out.map((w) => w.id)).toEqual(["d", "a", "b", "c"]);
|
||||
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("no-op when source equals target", () => {
|
||||
const out = reorderWidgets(initial, "b", "b");
|
||||
expect(out.map((w) => w.id)).toEqual(["a", "b", "c", "d"]);
|
||||
});
|
||||
|
||||
it("no-op when an id is missing — still normalises order", () => {
|
||||
const out = reorderWidgets(initial, "missing", "a");
|
||||
expect(out.map((w) => w.id)).toEqual(["a", "b", "c", "d"]);
|
||||
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("preserves stable sort by `order` before reordering", () => {
|
||||
const shuffled = [
|
||||
{ id: "c", order: 2 },
|
||||
{ id: "a", order: 0 },
|
||||
{ id: "d", order: 3 },
|
||||
{ id: "b", order: 1 },
|
||||
];
|
||||
const out = reorderWidgets(shuffled, "a", "d");
|
||||
// Sort first → [a, b, c, d]; move a → d position → [b, c, d, a].
|
||||
expect(out.map((w) => w.id)).toEqual(["b", "c", "d", "a"]);
|
||||
expect(out.map((w) => w.order)).toEqual([0, 1, 2, 3]);
|
||||
});
|
||||
|
||||
it("returns a fresh array — does not mutate the input", () => {
|
||||
const snapshot = JSON.parse(JSON.stringify(initial));
|
||||
reorderWidgets(initial, "a", "c");
|
||||
expect(initial).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* v1.4.47 W3 — Settings → AI "Hide Coach" toggle persistence contract.
|
||||
*
|
||||
* The card is a small client-only `<Switch>` that PATCHes
|
||||
* `/api/auth/me/disable-coach` and invalidates `queryKeys.authMe()` so
|
||||
* every Coach mount point in the app reacts on the next /me refetch.
|
||||
*
|
||||
* Test strategy: use the real `useMutation` + `QueryClient` from
|
||||
* `@tanstack/react-query`, stub `fetch`, and drive the Switch via a
|
||||
* `renderToStaticMarkup` pass plus a hand-invoked mutation call. The
|
||||
* file SSR-renders inside a `<QueryClientProvider>` so the hook
|
||||
* branches execute and the spies fire.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
|
||||
const authSpy = vi.fn<
|
||||
() => { user: AuthUser | null; isAuthenticated: boolean }
|
||||
>(() => ({ user: buildUser(false), isAuthenticated: true }));
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
"@/hooks/use-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => authSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
// Stub every other query hook the parent `<AiSection>` triggers so
|
||||
// the SSR pass doesn't blow up. The disable-coach card itself doesn't
|
||||
// trigger any GET — it only writes through PATCH on click.
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => "/settings/ai",
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
function buildUser(disableCoach: boolean): AuthUser {
|
||||
return {
|
||||
id: "user-1",
|
||||
username: "marc",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
gravatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
disableCoach,
|
||||
};
|
||||
}
|
||||
|
||||
import { AiSection } from "../ai-section";
|
||||
|
||||
function makeFetch(ok: boolean = true) {
|
||||
return vi.fn(async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = init?.body ? JSON.parse(init.body as string) : {};
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: { disableCoach: body.disableCoach ?? false },
|
||||
}),
|
||||
{
|
||||
status: ok ? 200 : 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
authSpy.mockClear();
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(false),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
vi.stubGlobal("fetch", makeFetch());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function render(): string {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">
|
||||
<AiSection />
|
||||
</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Settings — DisableCoachCard", () => {
|
||||
it("seeds the Switch in the off state for a default user", () => {
|
||||
const html = render();
|
||||
// The Switch primitive surfaces `data-state="unchecked"` when
|
||||
// `checked={false}` is passed by the parent — pin the contract so
|
||||
// the SSR shape matches the user's `disableCoach: false` field.
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
expect(html).toContain('data-state="unchecked"');
|
||||
});
|
||||
|
||||
it("seeds the Switch in the on state for an opted-out user", () => {
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(true),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const html = render();
|
||||
expect(html).toContain('data-testid="settings-disable-coach-switch"');
|
||||
// The shadcn Switch surfaces `data-state="checked"` on the root
|
||||
// when `checked={true}`. Pre-fix the card painted `unchecked` for
|
||||
// an opted-out user; this assertion guards that regression.
|
||||
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
|
||||
// everywhere." (English) — must surface verbatim because the QoL
|
||||
// findings doc cites this exact phrasing.
|
||||
expect(html).toContain("Hides the Coach button and drawer everywhere.");
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T18:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -147,7 +146,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T17:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T17:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -187,7 +185,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T08:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: "Withings refresh error: 503 - upstream",
|
||||
consecutiveFailures: 2,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -195,7 +192,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -229,7 +225,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-08T12:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: "Withings refresh error: 100 - invalid_grant",
|
||||
consecutiveFailures: 1,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -237,7 +232,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -265,7 +259,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -273,7 +266,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-08T10:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-08T10:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -300,7 +292,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-08T12:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: "Withings activity error: 293",
|
||||
consecutiveFailures: 30,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -308,7 +299,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -339,7 +329,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T18:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T18:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
{
|
||||
integration: "moodlog",
|
||||
@@ -347,7 +336,6 @@ describe("IntegrationsSection — single-status-display contract (A5)", () => {
|
||||
lastSuccessAt: "2026-05-09T17:00:00.000Z",
|
||||
lastAttemptAt: "2026-05-09T17:00:00.000Z",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -146,6 +146,29 @@ describe("settings sections — SSR smoke", () => {
|
||||
expect(html).not.toContain("settings.about.");
|
||||
});
|
||||
|
||||
it("<AboutSection> exposes a Replay-tour button for the W5 chain gate", () => {
|
||||
const html = render(<AboutSection />);
|
||||
// v1.4.47 W5 — the spotlight tour now auto-launches only ≥ 24 h
|
||||
// after the wizard finishes (see `shouldAutoLaunchTour` in
|
||||
// `components/onboarding/tour-launcher.tsx`). A first-day user who
|
||||
// *does* want the tour needs a discoverable manual trigger, and
|
||||
// Settings → About is the surface every user can find. Pin both
|
||||
// the data-testid and the visible copy so a future refactor can't
|
||||
// silently drop the button.
|
||||
expect(html).toContain('data-testid="about-replay-tour"');
|
||||
expect(html).toContain("Replay the tour");
|
||||
// No raw i18n key leaks past the i18n layer.
|
||||
expect(html).not.toContain("settings.about.tourReplay");
|
||||
});
|
||||
|
||||
it("<AboutSection> renders the German Replay-tour label end-to-end", () => {
|
||||
// Marc-voice + i18n parity: the W5 spec explicitly names this
|
||||
// surface "Tour ansehen" in German. Lock the resolved copy so a
|
||||
// future locale-bundle drift doesn't silently fall back to English.
|
||||
const html = render(<AboutSection />, "de");
|
||||
expect(html).toContain("Tour ansehen");
|
||||
});
|
||||
|
||||
it("<AiSection> renders the Insights card", () => {
|
||||
const html = render(<AiSection />);
|
||||
// v1.4.33 IW7 — section renamed from "AI Insights" to "Insights"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowUpCircle,
|
||||
BookOpen,
|
||||
Compass,
|
||||
ExternalLink,
|
||||
GitBranch,
|
||||
Info,
|
||||
@@ -13,6 +14,8 @@ 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 { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
@@ -116,6 +119,11 @@ export function UpdateBadge({
|
||||
export function AboutSection() {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
// Pull the auth user — only needed so the "Replay the tour" button
|
||||
// can stamp a per-user force-launch marker into sessionStorage. The
|
||||
// hook is cheap (a cached `/api/auth/me` read) and every settings
|
||||
// page already lives behind the same auth boundary.
|
||||
const { user } = useAuth();
|
||||
|
||||
const { data: version, isLoading } = useQuery({
|
||||
queryKey: queryKeys.apiVersion(),
|
||||
@@ -129,6 +137,66 @@ export function AboutSection() {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
// v1.4.47 W5 — onboarding chain-gate: the spotlight tour now auto-
|
||||
// launches only ≥ 24 h after the wizard finishes (see
|
||||
// `shouldAutoLaunchTour` in `components/onboarding/tour-launcher.tsx`).
|
||||
// The carousel + tour no longer stack into ~90 s of forced onboarding
|
||||
// on first visit. The tradeoff: a first-day user who *does* want the
|
||||
// tour needs a discoverable manual trigger. Settings → About is the
|
||||
// surface every user can find (every locale puts "About" right on the
|
||||
// settings shell), so it carries the replay button next to the
|
||||
// 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);
|
||||
|
||||
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 }),
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.36 W4f — the explicit "Check for updates" button is gone;
|
||||
// only the 24 h auto-check stays. When the auto-check reports
|
||||
// `newer_available`, a subtle upward-arrow badge appears next to
|
||||
@@ -317,6 +385,58 @@ export function AboutSection() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* v1.4.47 W5 — Replay tour card. The dashboard spotlight
|
||||
tour now auto-launches only ≥ 24 h after the wizard finishes,
|
||||
so a first-day user who wants to see it still needs a
|
||||
discoverable manual trigger. About is the surface every user
|
||||
can find (it's the standard "where am I?" stop in the
|
||||
settings shell), so the button lives here next to version +
|
||||
links. Mirrors the stack-on-mobile / right-align-on-desktop
|
||||
contract used by the Account section's "Restart onboarding
|
||||
tour" card so the two surfaces feel consistent. */}
|
||||
<div className="bg-card border-border rounded-xl border p-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:gap-2">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Compass className="text-primary h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("settings.about.tourReplay")}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.about.tourReplayHint")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleReplayTour}
|
||||
disabled={replayingTour}
|
||||
data-testid="about-replay-tour"
|
||||
className="w-full shrink-0 sm:w-auto"
|
||||
>
|
||||
{replayingTour ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin motion-reduce:animate-none" />
|
||||
) : (
|
||||
<Compass className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
{t("settings.about.tourReplay")}
|
||||
</Button>
|
||||
</div>
|
||||
{tourMessage && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`mt-2 text-xs ${
|
||||
tourMessageType === "success"
|
||||
? "text-dracula-green"
|
||||
: "text-destructive"
|
||||
}`}
|
||||
>
|
||||
{tourMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* v1.4.36 W4f — the dedicated "Updates" card with the
|
||||
manual "Check for updates" button is gone. The 24 h auto-
|
||||
check still runs on mount and writes its result into the
|
||||
|
||||
@@ -39,6 +39,7 @@ 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";
|
||||
@@ -292,6 +293,12 @@ export function AccountSection() {
|
||||
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
|
||||
|
||||
@@ -163,6 +163,128 @@ export function AiSection() {
|
||||
</header>
|
||||
|
||||
<AiInsightsCard isAuthenticated={isAuthenticated} />
|
||||
|
||||
{/* v1.4.47 W3 — per-user Coach opt-out. Lives below the provider
|
||||
card so users who want the surface gone never have to scroll
|
||||
through provider configuration before finding the toggle. */}
|
||||
<DisableCoachCard isAuthenticated={isAuthenticated} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W3 — "Hide Coach" toggle card.
|
||||
*
|
||||
* Persists via `PATCH /api/auth/me/disable-coach`; the response
|
||||
* invalidates `queryKeys.authMe()` so every Coach mount point on the
|
||||
* client (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the inline
|
||||
* `<CoachLaunchButton>` pill, the `/targets` page CTAs) re-renders
|
||||
* with the new `user.disableCoach` value on the next React Query
|
||||
* refetch tick — no full reload required.
|
||||
*
|
||||
* The optimistic-update pattern mirrors `<MoodReminderCard>`: the
|
||||
* Switch flips immediately, the mutation rolls back on error.
|
||||
*/
|
||||
function DisableCoachCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const { user } = useAuth();
|
||||
|
||||
// Optimistic-update flag — null means "no in-flight change",
|
||||
// otherwise it overrides the wire value until the mutation
|
||||
// settles. Mirrors the `<MoodReminderCard>` pattern so the Switch
|
||||
// reacts instantly to the user's tap.
|
||||
const [optimistic, setOptimistic] = useState<boolean | null>(null);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
const [msgType, setMsgType] = useState<"success" | "error" | null>(null);
|
||||
|
||||
const checked = optimistic ?? user?.disableCoach ?? false;
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (next: boolean) => {
|
||||
const res = await fetch("/api/auth/me/disable-coach", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ disableCoach: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(json.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
onSuccess: (next) => {
|
||||
setMsg(
|
||||
next
|
||||
? t("settings.ai.disableCoach.savedHidden")
|
||||
: t("settings.ai.disableCoach.savedShown"),
|
||||
);
|
||||
setMsgType("success");
|
||||
// Surface the new value to every Coach gate via the shared
|
||||
// `useAuth()` query — the gates re-render once React Query
|
||||
// re-fetches /api/auth/me with the updated `disableCoach` field.
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
|
||||
setOptimistic(null);
|
||||
},
|
||||
onError: (err) => {
|
||||
setOptimistic(null);
|
||||
setMsg(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: t("settings.ai.disableCoach.saveError"),
|
||||
);
|
||||
setMsgType("error");
|
||||
},
|
||||
});
|
||||
|
||||
function handleToggle(next: boolean) {
|
||||
setOptimistic(next);
|
||||
setMsg(null);
|
||||
setMsgType(null);
|
||||
mutation.mutate(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-labelledby="settings-ai-disable-coach-title"
|
||||
data-testid="settings-disable-coach-card"
|
||||
className="bg-card border-border space-y-3 rounded-xl border p-6"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="pr-2">
|
||||
<h2
|
||||
id="settings-ai-disable-coach-title"
|
||||
className="text-base font-semibold"
|
||||
>
|
||||
{t("settings.ai.disableCoach.title")}
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.ai.disableCoach.description")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
data-testid="settings-disable-coach-switch"
|
||||
checked={checked}
|
||||
onCheckedChange={handleToggle}
|
||||
disabled={!isAuthenticated || mutation.isPending}
|
||||
aria-label={t("settings.ai.disableCoach.toggleAria")}
|
||||
/>
|
||||
</div>
|
||||
{msg && (
|
||||
<p
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
className={
|
||||
msgType === "error"
|
||||
? "text-destructive text-xs"
|
||||
: "text-muted-foreground text-xs"
|
||||
}
|
||||
>
|
||||
{msg}
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
import { useId, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -9,7 +9,24 @@ import {
|
||||
Loader2,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
GripVertical,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
} from "@dnd-kit/core";
|
||||
import {
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
@@ -29,6 +46,30 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — pure reorder helper shared by the arrow buttons and the
|
||||
* @dnd-kit drag-end handler. Both surfaces produce the same `widgets[]`
|
||||
* 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.
|
||||
*/
|
||||
export function reorderWidgets(
|
||||
widgets: readonly { id: string; order: number }[],
|
||||
fromId: string,
|
||||
toId: string,
|
||||
): { id: string; order: number }[] {
|
||||
const sorted = [...widgets].sort((a, b) => a.order - b.order);
|
||||
const fromIdx = sorted.findIndex((w) => w.id === fromId);
|
||||
const toIdx = sorted.findIndex((w) => w.id === toId);
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) {
|
||||
return sorted.map((w, i) => ({ ...w, order: i }));
|
||||
}
|
||||
const next = [...sorted];
|
||||
const [removed] = next.splice(fromIdx, 1);
|
||||
next.splice(toIdx, 0, removed);
|
||||
return next.map((w, i) => ({ ...w, order: i }));
|
||||
}
|
||||
|
||||
const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
weight: "dashboard.weight",
|
||||
bp: "dashboard.bloodPressure",
|
||||
@@ -53,6 +94,25 @@ const WIDGET_LABEL_KEYS: Record<DashboardWidgetId, string> = {
|
||||
export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
// v1.4.47 W4 — stable id namespace for the drag-handle `aria-describedby`
|
||||
// tooltip. One hint paragraph is rendered once at the bottom of the list
|
||||
// and referenced by every drag handle in this section.
|
||||
const dragHintId = useId();
|
||||
|
||||
// v1.4.47 W4 — sensors: pointer for mouse/touch, keyboard for Tab + Space
|
||||
// + arrow-key reordering. The KeyboardSensor still works for users who
|
||||
// tab to the GripVertical handle; the legacy ArrowUp / ArrowDown buttons
|
||||
// below remain the primary keyboard surface for accessibility.
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
// Activation distance avoids the drag stealing every click on the row
|
||||
// switches — only a 6 px pointer-down → move counts as drag intent.
|
||||
activationConstraint: { distance: 6 },
|
||||
}),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
}),
|
||||
);
|
||||
|
||||
const { data: remote, isLoading } = useQuery({
|
||||
queryKey: queryKeys.dashboardWidgets(),
|
||||
@@ -152,6 +212,37 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — drag-and-drop reorder via @dnd-kit. Persists the same
|
||||
* `order` rewrite shape the arrow buttons already use, so save / cancel
|
||||
* / reset and the existing draft state machine work unchanged. The
|
||||
* pointer + keyboard sensors are wired in the same `useSensors` call;
|
||||
* keyboard a11y still also works through the legacy arrow buttons that
|
||||
* remain on every row.
|
||||
*/
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
if (!layout) return;
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
const reordered = reorderWidgets(
|
||||
layout.widgets,
|
||||
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 };
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// Presence of a draft implies dirty — no JSON comparison needed.
|
||||
const dirty = draft !== null && layout !== null;
|
||||
|
||||
@@ -259,7 +350,11 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
right-hand spacer reserves the width of two
|
||||
size-11/sm:size-9 buttons so the Tile / Chart column
|
||||
headers continue to line up with the switches below. */}
|
||||
{/* v1.4.47 W4 — column header spacer additionally reserves the
|
||||
width of the new drag-handle icon (w-7) so Tile / Chart
|
||||
alignment with the row switches below stays pixel-perfect. */}
|
||||
<div className="text-muted-foreground flex items-center gap-2 px-3 pb-1 text-[10px] font-medium tracking-wide uppercase">
|
||||
<span className="w-7" aria-hidden="true" />
|
||||
<span className="flex-1" aria-hidden="true" />
|
||||
<span className="w-12 text-center">
|
||||
{t("dashboard.layoutTileColumn")}
|
||||
@@ -267,83 +362,58 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
<span className="w-12 text-center">
|
||||
{t("dashboard.layoutChartColumn")}
|
||||
</span>
|
||||
<span
|
||||
className="w-22 sm:w-18"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="w-22 sm:w-18" aria-hidden="true" />
|
||||
</div>
|
||||
{[...layout.widgets]
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map((widget, index, arr) => {
|
||||
const labelKey = WIDGET_LABEL_KEYS[widget.id] ?? widget.id;
|
||||
const tileChecked =
|
||||
typeof widget.tileVisible === "boolean"
|
||||
? widget.tileVisible
|
||||
: widget.visible;
|
||||
return (
|
||||
// v1.4.29 — drag-list row compactness. The legacy
|
||||
// vertical 44+44-px arrow stack inflated each row
|
||||
// to ~116 px; the horizontal arrow pair on the
|
||||
// trailing edge drops the row to 48 px (`min-h-12`)
|
||||
// while preserving the 44-px mobile tap target
|
||||
// (`size-11`). `sm:size-9` shrinks the buttons
|
||||
// visually on desktop where pointer accuracy is
|
||||
// higher.
|
||||
<div
|
||||
key={widget.id}
|
||||
className="border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2"
|
||||
{(() => {
|
||||
const sortedWidgets = [...layout.widgets].sort(
|
||||
(a, b) => a.order - b.order,
|
||||
);
|
||||
const sortedIds = sortedWidgets.map((w) => w.id);
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={sortedIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<span
|
||||
className="flex-1 truncate text-sm"
|
||||
title={t(labelKey)}
|
||||
>
|
||||
{t(labelKey)}
|
||||
</span>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={tileChecked}
|
||||
onCheckedChange={(v) => toggleTile(widget.id, v)}
|
||||
aria-label={`${t(labelKey)} — ${t("dashboard.layoutTileColumn")}`}
|
||||
{sortedWidgets.map((widget, index, arr) => (
|
||||
<SortableWidgetRow
|
||||
key={widget.id}
|
||||
widget={widget}
|
||||
labelKey={WIDGET_LABEL_KEYS[widget.id] ?? widget.id}
|
||||
index={index}
|
||||
total={arr.length}
|
||||
dragHintId={dragHintId}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="widget-tile-switch"
|
||||
labels={{
|
||||
tileColumn: t("dashboard.layoutTileColumn"),
|
||||
chartColumn: t("dashboard.layoutChartColumn"),
|
||||
moveUp: t("dashboard.moveUp"),
|
||||
moveDown: t("dashboard.moveDown"),
|
||||
dragHandle: t("dashboard.dragHandle"),
|
||||
widgetLabel: t(
|
||||
WIDGET_LABEL_KEYS[widget.id] ?? widget.id,
|
||||
),
|
||||
}}
|
||||
onToggleTile={toggleTile}
|
||||
onToggleChart={toggle}
|
||||
onMove={move}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => toggle(widget.id, v)}
|
||||
aria-label={`${t(labelKey)} — ${t("dashboard.layoutChartColumn")}`}
|
||||
disabled={saveMutation.isPending}
|
||||
data-slot="widget-chart-switch"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => move(widget.id, -1)}
|
||||
disabled={index === 0 || saveMutation.isPending}
|
||||
aria-label={t("dashboard.moveUp")}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => move(widget.id, 1)}
|
||||
disabled={
|
||||
index === arr.length - 1 || saveMutation.isPending
|
||||
}
|
||||
aria-label={t("dashboard.moveDown")}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
})()}
|
||||
{/* 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -382,3 +452,153 @@ export function DashboardLayoutSection({ id }: { id: string }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.47 W4 — sortable row primitive extracted from the section render
|
||||
* so the @dnd-kit `useSortable` hook stays scoped to one row. Translation
|
||||
* strings are passed in pre-resolved (rather than calling `useTranslations`
|
||||
* inside) so this component stays cheap to re-render for the 13+ rows.
|
||||
*
|
||||
* The drag handle is the only listener-bearing surface — the row body
|
||||
* stays click-through so the switches and arrow buttons keep working.
|
||||
* Pointer activation has a 6 px distance constraint (configured on the
|
||||
* parent sensor) so a tap on the handle never accidentally drags.
|
||||
*/
|
||||
interface SortableWidgetRowProps {
|
||||
widget: {
|
||||
id: DashboardWidgetId;
|
||||
visible: boolean;
|
||||
tileVisible?: boolean;
|
||||
order: number;
|
||||
};
|
||||
labelKey: string;
|
||||
index: number;
|
||||
total: number;
|
||||
dragHintId: string;
|
||||
disabled: boolean;
|
||||
labels: {
|
||||
tileColumn: string;
|
||||
chartColumn: string;
|
||||
moveUp: string;
|
||||
moveDown: string;
|
||||
dragHandle: string;
|
||||
widgetLabel: string;
|
||||
};
|
||||
onToggleTile: (id: DashboardWidgetId, value: boolean) => void;
|
||||
onToggleChart: (id: DashboardWidgetId, value: boolean) => void;
|
||||
onMove: (id: DashboardWidgetId, delta: -1 | 1) => void;
|
||||
}
|
||||
|
||||
function SortableWidgetRow({
|
||||
widget,
|
||||
index,
|
||||
total,
|
||||
dragHintId,
|
||||
disabled,
|
||||
labels,
|
||||
onToggleTile,
|
||||
onToggleChart,
|
||||
onMove,
|
||||
}: SortableWidgetRowProps) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: widget.id });
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
const tileChecked =
|
||||
typeof widget.tileVisible === "boolean"
|
||||
? widget.tileVisible
|
||||
: widget.visible;
|
||||
|
||||
return (
|
||||
// v1.4.29 — row sized at 48 px (`min-h-12`) with 44-px mobile tap
|
||||
// targets preserved on the trailing arrow buttons (`size-11`),
|
||||
// shrunk to `sm:size-9` on desktop. v1.4.47 W4 — `isDragging`
|
||||
// raises the row visually (elevation + accent ring) so the ghost
|
||||
// overlay is unambiguous during a drag.
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
data-slot="widget-row"
|
||||
data-dragging={isDragging ? "true" : undefined}
|
||||
className={`border-border bg-background/30 flex min-h-12 items-center gap-2 rounded-md border px-3 py-2 ${
|
||||
isDragging ? "ring-primary z-10 opacity-90 shadow-lg ring-2" : ""
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={`${labels.dragHandle} — ${labels.widgetLabel}`}
|
||||
// v1.4.47 W4 — `aria-describedby` is set after `{...attributes}`
|
||||
// so our shared hint paragraph wins over dnd-kit's own announcer
|
||||
// hookup. The announcer still fires on drag-start / drag-over /
|
||||
// drag-end via the screenReaderInstructions slot below.
|
||||
aria-describedby={dragHintId}
|
||||
title={labels.dragHandle}
|
||||
disabled={disabled}
|
||||
data-slot="widget-drag-handle"
|
||||
// v1.4.47 W10 design-H1 — extend the WCAG 2.5.5 hit target to
|
||||
// 44 × 44 px via a `::before` pseudo-element while keeping the
|
||||
// visible GripVertical at 28 px (matches the Switch primitive
|
||||
// pattern from v1.4.43 W5-H1). v1.4.47 W10 design-M1 — drop
|
||||
// dnd-kit's CSS transition under prefers-reduced-motion.
|
||||
className="text-muted-foreground hover:text-foreground focus-visible:ring-ring focus-visible:ring-offset-background -m-1 relative inline-flex h-7 w-7 cursor-grab touch-none items-center justify-center rounded transition-colors before:absolute before:inset-[-8px] before:content-[''] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none active:cursor-grabbing disabled:cursor-not-allowed disabled:opacity-50 motion-reduce:transition-none"
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="flex-1 truncate text-sm" title={labels.widgetLabel}>
|
||||
{labels.widgetLabel}
|
||||
</span>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={tileChecked}
|
||||
onCheckedChange={(v) => onToggleTile(widget.id, v)}
|
||||
aria-label={`${labels.widgetLabel} — ${labels.tileColumn}`}
|
||||
disabled={disabled}
|
||||
data-slot="widget-tile-switch"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-12 justify-center">
|
||||
<Switch
|
||||
checked={widget.visible}
|
||||
onCheckedChange={(v) => onToggleChart(widget.id, v)}
|
||||
aria-label={`${labels.widgetLabel} — ${labels.chartColumn}`}
|
||||
disabled={disabled}
|
||||
data-slot="widget-chart-switch"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(widget.id, -1)}
|
||||
disabled={index === 0 || disabled}
|
||||
aria-label={labels.moveUp}
|
||||
>
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-11 sm:size-9"
|
||||
onClick={() => onMove(widget.id, 1)}
|
||||
disabled={index === total - 1 || disabled}
|
||||
aria-label={labels.moveDown}
|
||||
>
|
||||
<ArrowDown className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -73,7 +73,6 @@ interface IntegrationStatusViewModel {
|
||||
lastSuccessAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
lastError: string | null;
|
||||
consecutiveFailures: number;
|
||||
consecutiveFailuresByKind?: {
|
||||
transient: number;
|
||||
reauth_required: number;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { Textarea } from "../textarea";
|
||||
|
||||
describe("<Textarea>", () => {
|
||||
it("pins text-base on mobile and shrinks to text-sm on sm+ (iOS zoom defence)", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain("text-base");
|
||||
expect(html).toContain("sm:text-sm");
|
||||
});
|
||||
|
||||
it("floors at min-h-11 on mobile and min-h-9 on sm+ (WCAG 2.5.5 tap target)", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain("min-h-11");
|
||||
expect(html).toContain("sm:min-h-9");
|
||||
});
|
||||
|
||||
it("defaults autoComplete to off and tells password managers to skip", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain('autoComplete="off"');
|
||||
expect(html).toContain('data-lpignore="true"');
|
||||
expect(html).toContain('data-1p-ignore="true"');
|
||||
});
|
||||
|
||||
it("defaults autoCapitalize to sentences for prose-like free-text input", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain('autoCapitalize="sentences"');
|
||||
});
|
||||
|
||||
it("enables spellCheck by default", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain('spellCheck="true"');
|
||||
});
|
||||
|
||||
it("emits the data-slot attribute (shadcn convention)", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="note" />);
|
||||
expect(html).toContain('data-slot="textarea"');
|
||||
});
|
||||
|
||||
it("allows callers to override autoCapitalize for code / JSON input", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Textarea id="json" autoCapitalize="none" spellCheck={false} />,
|
||||
);
|
||||
expect(html).toContain('autoCapitalize="none"');
|
||||
expect(html).not.toContain('spellCheck="true"');
|
||||
});
|
||||
|
||||
it("respects an explicit autoComplete value and drops the ignore attrs", () => {
|
||||
const html = renderToStaticMarkup(<Textarea id="bio" autoComplete="on" />);
|
||||
expect(html).toContain('autoComplete="on"');
|
||||
expect(html).not.toContain('data-lpignore="true"');
|
||||
expect(html).not.toContain('data-1p-ignore="true"');
|
||||
});
|
||||
|
||||
it("merges caller className via cn() without dropping defaults", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Textarea id="note" className="font-mono" />,
|
||||
);
|
||||
expect(html).toContain("font-mono");
|
||||
// iOS zoom defence still present after merge
|
||||
expect(html).toContain("text-base");
|
||||
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
|
||||
id="note"
|
||||
rows={8}
|
||||
placeholder="Tell us more"
|
||||
maxLength={5000}
|
||||
/>,
|
||||
);
|
||||
expect(html).toContain('rows="8"');
|
||||
expect(html).toContain('placeholder="Tell us more"');
|
||||
expect(html).toContain('maxLength="5000"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Base textarea primitive — mirror of `<Input>` for multi-line input.
|
||||
*
|
||||
* v1.4.47 W2 — extracted the iOS-zoom defence + tap-target floor +
|
||||
* autocomplete defaults that the bugreport / medications-import /
|
||||
* side-effects / admin-feedback textareas were copy-pasting into a
|
||||
* single primitive. Six call sites previously each maintained their
|
||||
* own className string; any drift between them was a paper-cut waiting
|
||||
* to happen.
|
||||
*
|
||||
* What it bakes in:
|
||||
*
|
||||
* - `text-base sm:text-sm` — iOS Safari zooms the viewport on
|
||||
* focus when the font-size renders below 16 px, which yanks the
|
||||
* keyboard up and leaves the user lost. 16 px on mobile holds
|
||||
* the viewport; 14 px on `sm`+ keeps the compact desktop look.
|
||||
*
|
||||
* - `min-h-11 sm:min-h-9` — WCAG 2.5.5 / Apple HIG floor of 44 px
|
||||
* on touch screens, shrinking to 36 px once we know we have a
|
||||
* pointer (covers `rows={1}` single-line uses without dropping
|
||||
* under the tap-target floor).
|
||||
*
|
||||
* - `autoCapitalize="sentences"` — free-text fields read as prose
|
||||
* on iOS / Android by default. Caller can override with `"none"`
|
||||
* for code / JSON / IDs.
|
||||
*
|
||||
* - `spellCheck` defaults to `true` — same reasoning.
|
||||
*
|
||||
* - `autoComplete` defaults to `"off"` for the same reason as
|
||||
* `<Input>`: HealthLog is a health-data app, and we don't want
|
||||
* a password manager pasting the user's saved password into a
|
||||
* free-text textarea that we then persist server-side. Caller
|
||||
* can opt back in (e.g. `"on"`) per field.
|
||||
*
|
||||
* Composition is via `cn(…)` so callers can override anything — the
|
||||
* primitive defaults are the strict pass, not the only pass.
|
||||
*/
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(function Textarea(
|
||||
{ className, autoComplete, autoCapitalize, spellCheck, ...props },
|
||||
ref,
|
||||
) {
|
||||
const resolvedAutoComplete = autoComplete ?? "off";
|
||||
const resolvedAutoCapitalize = autoCapitalize ?? "sentences";
|
||||
const resolvedSpellCheck = spellCheck ?? true;
|
||||
const skipAutofill = resolvedAutoComplete === "off";
|
||||
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
data-slot="textarea"
|
||||
autoComplete={resolvedAutoComplete}
|
||||
autoCapitalize={resolvedAutoCapitalize}
|
||||
spellCheck={resolvedSpellCheck}
|
||||
data-lpignore={skipAutofill ? "true" : undefined}
|
||||
data-1p-ignore={skipAutofill ? "true" : undefined}
|
||||
className={cn(
|
||||
// iOS zoom defence + WCAG 2.5.5 tap-target floor; see the
|
||||
// primitive's docblock for the full reasoning.
|
||||
"border-input bg-background text-foreground placeholder:text-muted-foreground dark:bg-input/30 min-h-11 w-full rounded-md border px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 sm:min-h-9 sm:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { Textarea };
|
||||
+23
-1
@@ -23,13 +23,35 @@ export interface AuthUser {
|
||||
onboardingTourCompleted: boolean;
|
||||
gravatarUrl: string | null;
|
||||
glucoseUnit: string | null;
|
||||
/**
|
||||
* v1.4.47 W3 — per-user Coach opt-out. When `true`, every Coach
|
||||
* mount point (`<LayoutCoachFab>`, `<LayoutCoachMount>`, the
|
||||
* inline `<CoachLaunchButton>` pill, the `/targets` page CTA)
|
||||
* renders nothing. The gate sits BELOW the operator-level
|
||||
* `flags.coach` short-circuit — both must agree to render the
|
||||
* affordance. Defaults to `false` when the field is absent (e.g.
|
||||
* stale /me payload from a partial-deploy rollback).
|
||||
*/
|
||||
disableCoach: boolean;
|
||||
}
|
||||
|
||||
async function fetchMe(): Promise<AuthUser> {
|
||||
const res = await fetch("/api/auth/me");
|
||||
if (!res.ok) throw new Error("Not authenticated");
|
||||
const json = await res.json();
|
||||
return json.data;
|
||||
// v1.4.47 W3 — coerce `disableCoach` against `undefined` so a stale
|
||||
// /me payload from a partial-deploy rollback (older server image
|
||||
// without the field) keeps the Coach surface visible by default.
|
||||
const data = json.data as Partial<AuthUser> & {
|
||||
id: string;
|
||||
username: string;
|
||||
role: string;
|
||||
timezone: string;
|
||||
};
|
||||
return {
|
||||
...(data as AuthUser),
|
||||
disableCoach: data.disableCoach ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client";
|
||||
|
||||
import { useContext } from "react";
|
||||
import { QueryClientContext } from "@tanstack/react-query";
|
||||
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
|
||||
/**
|
||||
* v1.4.47 W3 — SSR-safe accessor for the per-user "Hide Coach" toggle.
|
||||
*
|
||||
* `useAuth()` calls `useQuery` directly and crashes when the caller
|
||||
* SSR-renders without a `<QueryClientProvider>` (the legacy
|
||||
* Coach-surface unit tests do exactly this). The cascade-test fixture
|
||||
* + the dedicated disable-cascade fixture both seed a client, but the
|
||||
* 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.
|
||||
*/
|
||||
export function useDisableCoach(): boolean {
|
||||
const hasClient = useContext(
|
||||
QueryClientContext as unknown as React.Context<unknown>,
|
||||
);
|
||||
if (!hasClient) return false;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
return useDisableCoachInner();
|
||||
}
|
||||
|
||||
function useDisableCoachInner(): boolean {
|
||||
const { user } = useAuth();
|
||||
return user?.disableCoach ?? false;
|
||||
}
|
||||
@@ -314,6 +314,14 @@ describe("Coach disable cascade invariant", () => {
|
||||
// Skip every `__tests__/` subtree — the discovery target is
|
||||
// the production gate, not the assertions about it.
|
||||
if (entry === "__tests__") continue;
|
||||
// v1.4.47 W3 — skip the Prisma client output. The generator
|
||||
// embeds the entire `prisma/schema.prisma` source into
|
||||
// `internal/class.ts` as a JSON-escaped `inlineSchema`
|
||||
// string, so any literal `flags.coach` mention in a schema
|
||||
// doc-comment leaks through here and trips the orphan check.
|
||||
// Generated derivatives are never the gate site we want to
|
||||
// pin; the production component file is.
|
||||
if (entry === "generated") continue;
|
||||
walk(full);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
import React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { CoachLaunchProvider } from "@/lib/insights/coach-launch-context";
|
||||
import {
|
||||
DEFAULT_ASSISTANT_FLAGS,
|
||||
type AssistantFlagSet,
|
||||
} from "@/hooks/use-feature-flags";
|
||||
import type { AuthUser } from "@/hooks/use-auth";
|
||||
|
||||
import { CoachLaunchButton } from "@/components/insights/coach-launch-button";
|
||||
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";
|
||||
|
||||
/**
|
||||
* v1.4.47 W3 — per-user "Hide Coach" opt-out invariant.
|
||||
*
|
||||
* The Coach disable cascade (`coach-cascade.test.tsx`) pins the
|
||||
* operator-level `flags.coach` gate. This sibling fixture pins the
|
||||
* per-user `disableCoach` gate that sits BELOW the operator flag —
|
||||
* either gate being off must hide every Coach affordance with no
|
||||
* grey-out, no error, no inert button, no DOM trace.
|
||||
*
|
||||
* Mocking strategy mirrors the cascade fixture: spy `useFeatureFlags`
|
||||
* to drive the operator matrix synchronously during SSR, spy
|
||||
* `useAuth` to drive the per-user `disableCoach` field. Both spies
|
||||
* are reset between iterations so a regression in either gate trips
|
||||
* the invariant in isolation.
|
||||
*/
|
||||
|
||||
const featureFlagsSpy = vi.fn<() => AssistantFlagSet>(
|
||||
() => DEFAULT_ASSISTANT_FLAGS,
|
||||
);
|
||||
vi.mock("@/hooks/use-feature-flags", async () => {
|
||||
const actual = await vi.importActual<
|
||||
typeof import("@/hooks/use-feature-flags")
|
||||
>("@/hooks/use-feature-flags");
|
||||
return {
|
||||
...actual,
|
||||
useFeatureFlags: () => featureFlagsSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
const authSpy = vi.fn<
|
||||
() => { user: AuthUser | null; isAuthenticated: boolean }
|
||||
>(() => ({ user: null, isAuthenticated: false }));
|
||||
vi.mock("@/hooks/use-auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@/hooks/use-auth")>(
|
||||
"@/hooks/use-auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
useAuth: () => authSpy(),
|
||||
};
|
||||
});
|
||||
|
||||
// The Coach gates read user.disableCoach through `useDisableCoach`,
|
||||
// which itself calls `useAuth()`. Mocking `useDisableCoach` directly
|
||||
// would bypass the useAuth call site and make the spy assertion
|
||||
// below trivially fail; instead we let the real `useDisableCoach`
|
||||
// pass through and capture the underlying `useAuth` calls.
|
||||
|
||||
function buildUser(disableCoach: boolean): AuthUser {
|
||||
// Minimal `AuthUser` shape — the Coach gates only read
|
||||
// `disableCoach`, but TypeScript requires the full interface to
|
||||
// satisfy the mock's return type. Everything else is filler.
|
||||
return {
|
||||
id: "test-user-id",
|
||||
username: "test",
|
||||
email: null,
|
||||
role: "USER",
|
||||
heightCm: null,
|
||||
dateOfBirth: null,
|
||||
gender: null,
|
||||
timezone: "Europe/Berlin",
|
||||
onboardingCompletedAt: null,
|
||||
onboardingTourCompleted: true,
|
||||
gravatarUrl: null,
|
||||
glucoseUnit: null,
|
||||
disableCoach,
|
||||
};
|
||||
}
|
||||
|
||||
function buildClient(): QueryClient {
|
||||
return new QueryClient({
|
||||
defaultOptions: { queries: { retry: 0 } },
|
||||
});
|
||||
}
|
||||
|
||||
function renderWithDisableCoach(
|
||||
node: React.ReactNode,
|
||||
disableCoach: boolean,
|
||||
coach: boolean = true,
|
||||
): string {
|
||||
featureFlagsSpy.mockImplementation(() => ({
|
||||
...DEFAULT_ASSISTANT_FLAGS,
|
||||
coach,
|
||||
}));
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(disableCoach),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
const client = buildClient();
|
||||
return renderToStaticMarkup(
|
||||
<QueryClientProvider client={client}>
|
||||
<I18nProvider initialLocale="en">{node}</I18nProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
interface DisableCoachSurface {
|
||||
name: string;
|
||||
mount: () => React.ReactNode;
|
||||
/**
|
||||
* Substring that proves the surface painted. Empty string means
|
||||
* "the surface SSR-renders to nothing even when on" (lazy-loaded
|
||||
* subtrees) and the negative assertion is the dispositive check.
|
||||
*/
|
||||
proofWhenVisible: string;
|
||||
}
|
||||
|
||||
const DISABLE_COACH_SURFACES: DisableCoachSurface[] = [
|
||||
{
|
||||
name: "LayoutCoachFab",
|
||||
mount: () => (
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachFab />
|
||||
</CoachLaunchProvider>
|
||||
),
|
||||
proofWhenVisible: 'data-slot="coach-launch-fab"',
|
||||
},
|
||||
{
|
||||
name: "LayoutCoachMount",
|
||||
mount: () => (
|
||||
<CoachLaunchProvider>
|
||||
<LayoutCoachMount />
|
||||
</CoachLaunchProvider>
|
||||
),
|
||||
// Lazy-loaded — SSR shape is empty even when visible. Negative
|
||||
// assertion still pins the gate via the `coach-*` slot grep.
|
||||
proofWhenVisible: "",
|
||||
},
|
||||
{
|
||||
name: "CoachLaunchButton inline pill",
|
||||
mount: () => (
|
||||
<CoachLaunchProvider>
|
||||
<CoachLaunchButton />
|
||||
</CoachLaunchProvider>
|
||||
),
|
||||
proofWhenVisible: 'data-slot="coach-launch-inline"',
|
||||
},
|
||||
{
|
||||
name: "SuggestedPrompts chip strip",
|
||||
mount: () => <SuggestedPrompts onPick={() => undefined} />,
|
||||
proofWhenVisible: 'data-slot="insights-suggested-prompts"',
|
||||
},
|
||||
{
|
||||
name: "HeroStrip action-row Ask the coach",
|
||||
mount: () => (
|
||||
<HeroStrip
|
||||
briefing={null}
|
||||
now={new Date(2026, 4, 10, 9, 0, 0)}
|
||||
onAskCoach={() => undefined}
|
||||
/>
|
||||
),
|
||||
proofWhenVisible: 'data-slot="insights-hero-strip-action-coach"',
|
||||
},
|
||||
];
|
||||
|
||||
describe("Coach per-user disableCoach invariant", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(console, "error").mockImplementation(() => undefined);
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
featureFlagsSpy.mockClear();
|
||||
authSpy.mockClear();
|
||||
featureFlagsSpy.mockImplementation(() => DEFAULT_ASSISTANT_FLAGS);
|
||||
authSpy.mockImplementation(() => ({
|
||||
user: buildUser(false),
|
||||
isAuthenticated: true,
|
||||
}));
|
||||
});
|
||||
|
||||
for (const surface of DISABLE_COACH_SURFACES) {
|
||||
it(`${surface.name} — paints when user.disableCoach is false`, () => {
|
||||
if (!surface.proofWhenVisible) return;
|
||||
const html = renderWithDisableCoach(surface.mount(), false);
|
||||
expect(html).toContain(surface.proofWhenVisible);
|
||||
});
|
||||
|
||||
it(`${surface.name} — vanishes when user.disableCoach is true`, () => {
|
||||
const html = renderWithDisableCoach(surface.mount(), true);
|
||||
if (surface.proofWhenVisible) {
|
||||
expect(html).not.toContain(surface.proofWhenVisible);
|
||||
}
|
||||
// No Coach-marked mount point may paint when the per-user gate
|
||||
// is on. Pin the negative shape against the shared prefix so a
|
||||
// future surface that grows a new `coach-*` slot without the
|
||||
// disableCoach gate fails this test by default.
|
||||
expect(html).not.toMatch(/data-slot="coach-[a-z]/);
|
||||
expect(html).not.toContain('data-slot="insights-suggested-prompts"');
|
||||
expect(html).not.toContain(
|
||||
'data-slot="insights-hero-strip-action-coach"',
|
||||
);
|
||||
expect(html).not.toContain('data-slot="insights-hero-strip-prompts"');
|
||||
});
|
||||
|
||||
it(`${surface.name} — also vanishes when flags.coach is false (regression pin)`, () => {
|
||||
// The legacy operator-level gate must keep working in the
|
||||
// presence of the new per-user gate. This guards against a
|
||||
// regression where a contributor "simplifies" the two checks
|
||||
// into one and accidentally drops the operator branch.
|
||||
const html = renderWithDisableCoach(surface.mount(), false, false);
|
||||
if (surface.proofWhenVisible) {
|
||||
expect(html).not.toContain(surface.proofWhenVisible);
|
||||
}
|
||||
expect(html).not.toMatch(/data-slot="coach-[a-z]/);
|
||||
});
|
||||
}
|
||||
|
||||
it("`useAuth` gate is consulted on every Coach surface", () => {
|
||||
// A `disableCoach: true` render must trip the `useAuth` spy at
|
||||
// least once. Otherwise a future regression that drops the
|
||||
// `useAuth()` call site silently leaks the affordance.
|
||||
for (const surface of DISABLE_COACH_SURFACES) {
|
||||
authSpy.mockClear();
|
||||
renderWithDisableCoach(surface.mount(), true);
|
||||
expect(
|
||||
authSpy,
|
||||
`${surface.name} did not consult useAuth() during render`,
|
||||
).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -218,7 +218,12 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", ()
|
||||
lastError: "enc(Withings connection is missing the user.activity scope.)",
|
||||
});
|
||||
// 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.
|
||||
// Neither field appears in the update payload — the existing
|
||||
// bucket values are preserved exactly.
|
||||
expect(upsertArgs.update).not.toHaveProperty("consecutiveFailures");
|
||||
expect(upsertArgs.update).not.toHaveProperty("consecutiveFailuresByKind");
|
||||
|
||||
// No admin alert fired.
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
@@ -272,6 +277,9 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", ()
|
||||
// First-ever sync attempt on a legacy connection: no row in the
|
||||
// table. Helper must create the row at counter=0 so a later
|
||||
// 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.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce(null);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
@@ -290,7 +298,11 @@ describe("parkIntegrationAtReauth — silent scope-skip park (v1.4.27 F20)", ()
|
||||
userId: "u-new",
|
||||
integration: "withings",
|
||||
state: "error_reauth",
|
||||
consecutiveFailures: 0,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
});
|
||||
expect(dispatchNotification).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -95,9 +95,11 @@ describe("recordSyncSuccess", () => {
|
||||
lastSuccessAt: expect.any(Date),
|
||||
lastAttemptAt: expect.any(Date),
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
// v1.4.43 W14 — success clears every per-kind bucket too so
|
||||
// the next failure starts from zero in its own bucket.
|
||||
// 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
|
||||
// counter write now.
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
@@ -111,9 +113,9 @@ describe("recordSyncSuccess", () => {
|
||||
|
||||
describe("recordSyncFailure — under threshold", () => {
|
||||
it("increments the counter and does NOT dispatch an admin alert", async () => {
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await recordSyncFailure({
|
||||
userId: "u1",
|
||||
@@ -140,8 +142,19 @@ describe("recordSyncFailure — under threshold", () => {
|
||||
|
||||
describe("recordSyncFailure — at threshold", () => {
|
||||
it("dispatches an admin Telegram alert when failure count reaches default threshold (3)", async () => {
|
||||
// v1.4.47 W1 — the alert ladder reads Math.max(...buckets) so the
|
||||
// existing row must seed the bucket at N-1 before the in-memory
|
||||
// increment produces alertSignal = N.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 3,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
@@ -192,9 +205,18 @@ describe("recordSyncFailure — at threshold", () => {
|
||||
|
||||
it("uses the custom threshold from INTEGRATION_FAILURE_ALERT_THRESHOLD", async () => {
|
||||
process.env.INTEGRATION_FAILURE_ALERT_THRESHOLD = "5";
|
||||
// 4 < 5 → should not page yet
|
||||
// 4 < 5 → should not page yet. Seed the existing bucket at 3 so
|
||||
// the in-memory increment lands at 4.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 3,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 4,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
await recordSyncFailure({
|
||||
@@ -210,10 +232,21 @@ describe("recordSyncFailure — at threshold", () => {
|
||||
describe("recordSyncFailure — alert window", () => {
|
||||
it("does NOT dispatch a second alert within 24h on the same streak", async () => {
|
||||
const now = Date.now();
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 4,
|
||||
// Existing bucket at 3 → in-memory increment lands at 4, above
|
||||
// the default 3-strike threshold. The 24h alertedAt guard must
|
||||
// suppress the duplicate page.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 3,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: new Date(now - 60 * 60 * 1000), // 1h ago
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
alertedAt: new Date(now - 60 * 60 * 1000),
|
||||
} as never);
|
||||
|
||||
await recordSyncFailure({
|
||||
userId: "u1",
|
||||
@@ -226,10 +259,19 @@ describe("recordSyncFailure — alert window", () => {
|
||||
});
|
||||
|
||||
it("DOES dispatch a fresh alert after 24h have elapsed", async () => {
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 6,
|
||||
// Existing bucket at 5 → in-memory increment lands at 6.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 5,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: new Date(Date.now() - 25 * 60 * 60 * 1000), // 25h ago
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
alertedAt: new Date(Date.now() - 25 * 60 * 60 * 1000),
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
email: "u@example.com",
|
||||
} as never);
|
||||
@@ -253,9 +295,9 @@ describe("recordSyncFailure — alert window", () => {
|
||||
|
||||
describe("recordSyncFailure — reauth_required", () => {
|
||||
it("marks state=error_reauth and audits with kind=reauth_required", async () => {
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
await recordSyncFailure({
|
||||
userId: "u1",
|
||||
integration: "withings",
|
||||
@@ -286,9 +328,9 @@ describe("recordSyncFailure — persistent (v1.4.42 W6)", () => {
|
||||
// integration — the next sync should still run because the
|
||||
// mismatch can be one-sided (e.g. Withings introducing a new
|
||||
// required field that the client release fixes).
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
await recordSyncFailure({
|
||||
userId: "u1",
|
||||
integration: "withings",
|
||||
@@ -311,8 +353,18 @@ describe("recordSyncFailure — persistent (v1.4.42 W6)", () => {
|
||||
});
|
||||
|
||||
it("at threshold, the admin alert message includes the 'persistent error' label", async () => {
|
||||
// Seed persistent bucket at 2 → in-memory increment lands at 3,
|
||||
// hitting the default threshold.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
persistent: 2,
|
||||
},
|
||||
persistentFailureStartedAt: new Date(Date.now() - 60 * 60 * 1000),
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 3,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
@@ -342,8 +394,18 @@ describe("recordSyncFailure — persistent (v1.4.42 W6)", () => {
|
||||
|
||||
describe("recordSyncFailure — admin Telegram skipped silently when no admins", () => {
|
||||
it("logs a wide-event warning but does NOT throw when no admin users exist", async () => {
|
||||
// Seed bucket at 2 so the in-memory increment hits the threshold
|
||||
// and the no-admins branch is the one being exercised.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
},
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 3,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
@@ -418,8 +480,9 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
|
||||
expect(args.update).toEqual({
|
||||
state: "disconnected",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
// 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.
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
@@ -439,9 +502,10 @@ describe("markReauthRequired / markDisconnected / markReconnected", () => {
|
||||
expect(args.update).toEqual({
|
||||
state: "connected",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
// 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.
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
@@ -475,7 +539,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
it("increments ONLY the matching bucket on each failure", async () => {
|
||||
// Existing row: transient bucket already at 2 from earlier hiccups.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailures: 2,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
@@ -485,7 +548,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 3,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
|
||||
@@ -513,7 +575,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
it("preserves persistentFailureStartedAt across multiple persistent failures", async () => {
|
||||
const streakStart = new Date(Date.now() - 60 * 60 * 1000); // 1h ago
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailures: 5,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
@@ -523,7 +584,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
alertedAt: new Date(Date.now() - 30 * 60 * 1000), // already alerted
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 6,
|
||||
alertedAt: new Date(Date.now() - 30 * 60 * 1000),
|
||||
} as never);
|
||||
|
||||
@@ -552,7 +612,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
it("a transient failure does NOT reset the persistent bucket nor the streak anchor", async () => {
|
||||
const streakStart = new Date(Date.now() - 6 * 60 * 60 * 1000); // 6h ago
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailures: 4,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
@@ -562,7 +621,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 5,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
|
||||
@@ -591,7 +649,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
it("flips state to `parked` and writes an audit row once persistent streak > 24h", async () => {
|
||||
const streakStart = new Date(Date.now() - 25 * 60 * 60 * 1000); // 25h ago
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailures: 30,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
@@ -601,7 +658,6 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
alertedAt: new Date(Date.now() - 23 * 60 * 60 * 1000),
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 31,
|
||||
alertedAt: new Date(Date.now() - 23 * 60 * 60 * 1000),
|
||||
} as never);
|
||||
|
||||
@@ -629,18 +685,20 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("back-fills the bucket from the legacy single counter on a pre-migration row", async () => {
|
||||
// Legacy row: counter is 3, no bucket JSON yet (predates 0075).
|
||||
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
|
||||
// (`consecutiveFailuresByKind: null`) starts from a zero envelope
|
||||
// on the next failure; the matching bucket ticks to 1 in a single
|
||||
// write.
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
consecutiveFailures: 3,
|
||||
consecutiveFailuresByKind: null,
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce({
|
||||
consecutiveFailures: 4,
|
||||
alertedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.integrationStatus.upsert).mockResolvedValueOnce(
|
||||
{} as never,
|
||||
);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce({
|
||||
email: "u@example.com",
|
||||
} as never);
|
||||
@@ -659,21 +717,89 @@ describe("recordSyncFailure — v1.4.43 W14 per-kind buckets", () => {
|
||||
|
||||
const upsertArgs = vi.mocked(prisma.integrationStatus.upsert).mock
|
||||
.calls[0][0];
|
||||
// The 3 legacy failures get bucketed into the CURRENT failure's
|
||||
// kind (reauth_required) — that's the kind the row was last
|
||||
// tagged with, and back-filling into a different kind would
|
||||
// under-count the existing streak. v1.4.43 W10 senior-dev M-1 —
|
||||
// the back-fill branch additionally increments the bucket so the
|
||||
// post-write bucket count matches the legacy column's post-write
|
||||
// value (legacy: 3 → 4, bucket: 0 → 3 → 4).
|
||||
expect(upsertArgs.update.consecutiveFailuresByKind).toEqual({
|
||||
transient: 0,
|
||||
reauth_required: 4,
|
||||
reauth_required: 1,
|
||||
persistent: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── v1.4.47 W1 — migration 0076 down-script reversibility ──────────────
|
||||
//
|
||||
// Migration 0076 drops `consecutive_failures` and documents a down-script
|
||||
// that restores the legacy column via
|
||||
//
|
||||
// SET "consecutive_failures" = COALESCE(
|
||||
// GREATEST(
|
||||
// ("consecutive_failures_by_kind"->>'transient')::int,
|
||||
// ("consecutive_failures_by_kind"->>'reauth_required')::int,
|
||||
// ("consecutive_failures_by_kind"->>'persistent')::int
|
||||
// ),
|
||||
// 0
|
||||
// )
|
||||
//
|
||||
// The recipe maps a bucket payload to a single integer using the SAME
|
||||
// `Math.max(...)` reducer the live alert ladder uses, so a rollback
|
||||
// lands every row at the post-v1.4.43 counter value (= the running
|
||||
// alert signal). This test locks in that contract at the JS level so
|
||||
// any future tweak to the SQL recipe has to match the in-memory
|
||||
// behaviour the writer and the alert ladder share.
|
||||
|
||||
describe("v1.4.47 W1 — down-script restores legacy consecutiveFailures via Math.max", () => {
|
||||
/**
|
||||
* In-memory mirror of the migration's down-script projection. Returns
|
||||
* the integer the legacy `consecutive_failures` column would land at
|
||||
* for a given bucket payload. Kept inline so the test file is the
|
||||
* single source of truth for the recipe.
|
||||
*/
|
||||
function projectLegacyCounter(
|
||||
buckets: { transient: number; reauth_required: number; persistent: number } | null,
|
||||
): number {
|
||||
if (!buckets) return 0;
|
||||
return Math.max(
|
||||
buckets.transient,
|
||||
buckets.reauth_required,
|
||||
buckets.persistent,
|
||||
);
|
||||
}
|
||||
|
||||
it("zero buckets map to 0 (post-success or fresh row)", () => {
|
||||
expect(
|
||||
projectLegacyCounter({ transient: 0, reauth_required: 0, persistent: 0 }),
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("a NULL bucket payload coerces to 0 (rollback against a never-written row)", () => {
|
||||
expect(projectLegacyCounter(null)).toBe(0);
|
||||
});
|
||||
|
||||
it("picks the max across kinds — a 28-deep persistent streak rolls back to 28", () => {
|
||||
// The v1.4.43 W14 production scenario: persistent burst at 28, a
|
||||
// single transient hiccup mid-streak at 2, no reauth. The legacy
|
||||
// column tracked the running streak total and would have been at
|
||||
// 28 (no live transient-driven increment after the persistent
|
||||
// streak anchored). The down-script restores the same 28.
|
||||
expect(
|
||||
projectLegacyCounter({
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
persistent: 28,
|
||||
}),
|
||||
).toBe(28);
|
||||
});
|
||||
|
||||
it("matches the live alert ladder's reducer (Math.max over Object.values)", () => {
|
||||
// The whole point of the recipe: a rollback restores the exact
|
||||
// integer the live alert ladder reads. If `recordSyncFailure`
|
||||
// pages on alertSignal = 7 today, the rolled-back column lands
|
||||
// at 7 too.
|
||||
const buckets = { transient: 1, reauth_required: 7, persistent: 3 };
|
||||
const liveSignal = Math.max(...Object.values(buckets));
|
||||
expect(projectLegacyCounter(buckets)).toBe(liveSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeIntegrationFromPark", () => {
|
||||
it("clears a parked row back to connected with all buckets zeroed", async () => {
|
||||
vi.mocked(prisma.integrationStatus.findUnique).mockResolvedValueOnce({
|
||||
@@ -691,7 +817,8 @@ describe("resumeIntegrationFromPark", () => {
|
||||
expect(upsertArgs.update).toEqual({
|
||||
state: "connected",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
// v1.4.47 W1 — legacy `consecutiveFailures` column dropped
|
||||
// (migration 0076); the bucket reset is the only counter write.
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
|
||||
@@ -108,8 +108,8 @@ export function getPersistentFailureThreshold(): number {
|
||||
/**
|
||||
* Re-alert window: once we've paged on a streak we hold the alert for
|
||||
* 24h before paging again on the same streak (idempotency). The streak
|
||||
* is implicitly "reset" by a single success, which clears
|
||||
* consecutiveFailures and alertedAt — so a flapping integration that
|
||||
* is implicitly "reset" by a single success, which clears every
|
||||
* per-kind bucket and `alertedAt` — so a flapping integration that
|
||||
* succeeds once an hour will not page repeatedly.
|
||||
*/
|
||||
const ALERT_REPEAT_WINDOW_MS = 24 * 60 * 60 * 1000;
|
||||
@@ -130,9 +130,10 @@ export interface IntegrationStatusSnapshot {
|
||||
lastSuccessAt: string | null;
|
||||
lastAttemptAt: string | null;
|
||||
lastError: string | null;
|
||||
consecutiveFailures: number;
|
||||
/** v1.4.43 W14 — per-kind bucketed counters; `null` for rows
|
||||
* that predate the migration (back-filled on next write). */
|
||||
* that have never recorded a write yet. v1.4.47 W1 — sole source
|
||||
* of truth now that the legacy `consecutiveFailures` column is
|
||||
* gone. */
|
||||
consecutiveFailuresByKind: ConsecutiveFailuresByKind | null;
|
||||
}
|
||||
|
||||
@@ -147,7 +148,7 @@ export type ConsecutiveFailuresByKind = Record<FailureKind, number>;
|
||||
* Type guard for the JSON payload Prisma returns for the
|
||||
* `consecutiveFailuresByKind` column. Anything that's not a plain
|
||||
* object with three numeric keys is treated as "no value yet" so the
|
||||
* back-fill path runs on the next write.
|
||||
* writer starts from a zero envelope.
|
||||
*/
|
||||
function isFailureBucketObject(
|
||||
value: unknown,
|
||||
@@ -165,9 +166,8 @@ function isFailureBucketObject(
|
||||
|
||||
/**
|
||||
* Read the JSON `consecutiveFailuresByKind` column off the Prisma row
|
||||
* with a typed view. Returns `null` when the row predates the
|
||||
* migration so callers can back-fill from the legacy
|
||||
* `consecutiveFailures` integer.
|
||||
* with a typed view. Returns `null` for rows that have never written a
|
||||
* bucket payload — callers seed a fresh zero envelope.
|
||||
*/
|
||||
function readBucketColumn(value: unknown): ConsecutiveFailuresByKind | null {
|
||||
return isFailureBucketObject(value) ? value : null;
|
||||
@@ -181,24 +181,6 @@ function zeroBuckets(): ConsecutiveFailuresByKind {
|
||||
return { transient: 0, reauth_required: 0, persistent: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-fill the per-kind buckets for a legacy row that predates the
|
||||
* v1.4.43 W14 migration. We bucket the existing
|
||||
* `consecutiveFailures` count into the kind matching the CURRENT
|
||||
* failure (so a row sitting at `error_reauth` with 4 failures
|
||||
* back-fills into `reauth_required: 4`). Subsequent failures of a
|
||||
* different kind then increment their own bucket without losing the
|
||||
* legacy count.
|
||||
*/
|
||||
function backfillBuckets(
|
||||
legacyCount: number,
|
||||
currentKind: FailureKind,
|
||||
): ConsecutiveFailuresByKind {
|
||||
const buckets = zeroBuckets();
|
||||
buckets[currentKind] = Math.max(0, legacyCount);
|
||||
return buckets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the current snapshot. Returns a synthetic "connected, never
|
||||
* attempted" record when no row exists yet — the UI treats this as
|
||||
@@ -218,7 +200,6 @@ export async function getIntegrationStatus(
|
||||
lastSuccessAt: null,
|
||||
lastAttemptAt: null,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
consecutiveFailuresByKind: null,
|
||||
};
|
||||
}
|
||||
@@ -228,7 +209,6 @@ export async function getIntegrationStatus(
|
||||
lastSuccessAt: row.lastSuccessAt?.toISOString() ?? null,
|
||||
lastAttemptAt: row.lastAttemptAt?.toISOString() ?? null,
|
||||
lastError: row.lastError ? safeDecryptError(row.lastError) : null,
|
||||
consecutiveFailures: row.consecutiveFailures,
|
||||
consecutiveFailuresByKind: readBucketColumn(row.consecutiveFailuresByKind),
|
||||
};
|
||||
}
|
||||
@@ -268,9 +248,9 @@ export async function recordSyncSuccess(
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
// v1.4.43 W14 — a success resets ALL per-kind buckets back to zero
|
||||
// and clears the persistent-streak start timestamp. The legacy
|
||||
// single-column `consecutiveFailures` zero stays for backward
|
||||
// compatibility with v1.4.43 readers that haven't migrated yet.
|
||||
// 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.
|
||||
await prisma.integrationStatus.upsert({
|
||||
where: { userId_integration: { userId, integration } },
|
||||
create: {
|
||||
@@ -279,7 +259,6 @@ export async function recordSyncSuccess(
|
||||
state: "connected",
|
||||
lastSuccessAt: now,
|
||||
lastAttemptAt: now,
|
||||
consecutiveFailures: 0,
|
||||
consecutiveFailuresByKind: zeroBuckets(),
|
||||
},
|
||||
update: {
|
||||
@@ -287,7 +266,6 @@ export async function recordSyncSuccess(
|
||||
lastSuccessAt: now,
|
||||
lastAttemptAt: now,
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
consecutiveFailuresByKind: zeroBuckets(),
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
@@ -307,8 +285,6 @@ export interface RecordSyncFailureInput {
|
||||
/**
|
||||
* Record a sync failure. Always:
|
||||
* - increments the per-kind bucket in `consecutiveFailuresByKind`
|
||||
* (and the legacy `consecutive_failures` integer for one release
|
||||
* as a fallback for un-migrated readers)
|
||||
* - persists the encrypted error message
|
||||
* - writes one `AuditLog` row with `integrations.sync.failed`
|
||||
*
|
||||
@@ -326,10 +302,10 @@ export interface RecordSyncFailureInput {
|
||||
*
|
||||
* v1.4.43 W14 — per-kind counter migration. Each failure increments
|
||||
* ONLY its own bucket; a transient hiccup followed by a persistent
|
||||
* failure no longer masks the persistent streak's true age. The
|
||||
* legacy single-column `consecutiveFailures` integer continues to be
|
||||
* incremented so the v1.4.43 alert ladder + UI consumers keep working
|
||||
* unchanged; v1.4.44 will drop it once every reader migrates.
|
||||
* 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
|
||||
* and the back-fill branch is gone.
|
||||
*/
|
||||
export async function recordSyncFailure(
|
||||
input: RecordSyncFailureInput,
|
||||
@@ -342,12 +318,9 @@ export async function recordSyncFailure(
|
||||
// (a) compute the new per-kind bucket value
|
||||
// (b) decide whether the persistent streak has exceeded the
|
||||
// park threshold
|
||||
// (c) back-fill `consecutiveFailuresByKind` for rows that
|
||||
// predate the v1.4.43 W14 migration.
|
||||
const existing = await prisma.integrationStatus.findUnique({
|
||||
where: { userId_integration: { userId, integration } },
|
||||
select: {
|
||||
consecutiveFailures: true,
|
||||
consecutiveFailuresByKind: true,
|
||||
persistentFailureStartedAt: true,
|
||||
alertedAt: true,
|
||||
@@ -355,11 +328,9 @@ export async function recordSyncFailure(
|
||||
});
|
||||
|
||||
// Resolve the starting bucket envelope:
|
||||
// - existing JSON value if present (post-migration row)
|
||||
// - back-fill from the legacy integer if the row predates the
|
||||
// migration (counts go into the CURRENT failure's bucket so the
|
||||
// persistent streak is dated from this write, not from zero)
|
||||
// - zero envelope for the first-ever write
|
||||
// - existing JSON value if present
|
||||
// - zero envelope for a row that has never written a bucket payload
|
||||
// or for the first-ever write of this (user, integration) pair
|
||||
const startingBuckets = readBucketColumn(existing?.consecutiveFailuresByKind);
|
||||
|
||||
// Snapshot the persistent-bucket count BEFORE we increment so the
|
||||
@@ -374,31 +345,12 @@ export async function recordSyncFailure(
|
||||
// to the audit-log read path below).
|
||||
const buckets: ConsecutiveFailuresByKind = startingBuckets
|
||||
? { ...startingBuckets }
|
||||
: existing
|
||||
? backfillBuckets(existing.consecutiveFailures, kind)
|
||||
: zeroBuckets();
|
||||
: zeroBuckets();
|
||||
|
||||
// Increment only the bucket matching this failure's kind.
|
||||
// The other two buckets stay at their current value so a persistent
|
||||
// streak isn't reset by an intervening transient hiccup.
|
||||
//
|
||||
// v1.4.43 W10 senior-dev M-1 — back-fill branch also increments. The
|
||||
// legacy column writes `{ increment: 1 }` to advance from N → N+1; the
|
||||
// back-fill seed sets `buckets[kind] = legacyCount` (= N), so without
|
||||
// the +1 here the bucket would land at N while the legacy column reads
|
||||
// N+1. When v1.4.44 drops the legacy column, every back-filled row
|
||||
// would page exactly one failure too late.
|
||||
if (startingBuckets) {
|
||||
// Post-migration row — increment the actual bucket.
|
||||
buckets[kind] = (buckets[kind] ?? 0) + 1;
|
||||
} else if (!existing) {
|
||||
// First-ever write — bucket starts at zero, this failure makes 1.
|
||||
buckets[kind] = 1;
|
||||
} else {
|
||||
// Back-fill path — seeded with the legacy count; this failure is
|
||||
// the +1 that brings both the legacy column AND the bucket to N+1.
|
||||
buckets[kind] = (buckets[kind] ?? 0) + 1;
|
||||
}
|
||||
buckets[kind] = (buckets[kind] ?? 0) + 1;
|
||||
|
||||
// Track the persistent-streak start so the >24h park check has a
|
||||
// wall-clock anchor. Only stamped on the FIRST persistent failure of
|
||||
@@ -446,7 +398,6 @@ export async function recordSyncFailure(
|
||||
state: newState,
|
||||
lastAttemptAt: now,
|
||||
lastError: encryptedError,
|
||||
consecutiveFailures: 1,
|
||||
consecutiveFailuresByKind: buckets,
|
||||
persistentFailureStartedAt: isPersistent ? now : null,
|
||||
},
|
||||
@@ -454,7 +405,6 @@ export async function recordSyncFailure(
|
||||
state: newState,
|
||||
lastAttemptAt: now,
|
||||
lastError: encryptedError,
|
||||
consecutiveFailures: { increment: 1 },
|
||||
consecutiveFailuresByKind: buckets,
|
||||
persistentFailureStartedAt,
|
||||
},
|
||||
@@ -464,6 +414,15 @@ export async function recordSyncFailure(
|
||||
// it. The auth/audit helper is its own DB write so it's safe to call
|
||||
// serially without bloating latency in the success path (which never
|
||||
// calls this).
|
||||
//
|
||||
// v1.4.47 W1 — `attemptNumber` is now sourced from the bucket sum
|
||||
// (the legacy `consecutiveFailures` column was dropped). The sum
|
||||
// matches the legacy column's value for any row written after
|
||||
// v1.4.43: a transient burst followed by a single persistent failure
|
||||
// shows `attemptNumber = transient + persistent`, which is the same
|
||||
// running total the legacy integer carried.
|
||||
const bucketTotal =
|
||||
buckets.transient + buckets.reauth_required + buckets.persistent;
|
||||
await auditLog("integrations.sync.failed", {
|
||||
userId,
|
||||
details: {
|
||||
@@ -471,7 +430,7 @@ export async function recordSyncFailure(
|
||||
kind,
|
||||
errorCode: errorCode ?? null,
|
||||
message,
|
||||
attemptNumber: row.consecutiveFailures,
|
||||
attemptNumber: bucketTotal,
|
||||
bucketCount: buckets[kind],
|
||||
state: newState,
|
||||
},
|
||||
@@ -487,15 +446,11 @@ export async function recordSyncFailure(
|
||||
//
|
||||
// v1.4.43 W14 — the threshold check reads `Math.max(...buckets)` so
|
||||
// a row with a 3-deep persistent streak still pages even when the
|
||||
// transient bucket sat at 0 (the legacy integer was the running
|
||||
// total and would have paged at the same moment).
|
||||
// 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.
|
||||
const threshold = getPersistentFailureThreshold();
|
||||
const alertSignal = Math.max(
|
||||
row.consecutiveFailures,
|
||||
buckets.transient,
|
||||
buckets.reauth_required,
|
||||
buckets.persistent,
|
||||
);
|
||||
const alertSignal = Math.max(...Object.values(buckets));
|
||||
if (alertSignal >= threshold) {
|
||||
const previouslyAlerted =
|
||||
row.alertedAt &&
|
||||
@@ -577,7 +532,6 @@ export async function resumeIntegrationFromPark(
|
||||
update: {
|
||||
state: "connected",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
consecutiveFailuresByKind: zeroBuckets(),
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
@@ -598,7 +552,7 @@ export async function resumeIntegrationFromPark(
|
||||
* Park a connection at `error_reauth` from a deliberate scope-skip
|
||||
* short-circuit. Unlike `recordSyncFailure`, this helper:
|
||||
*
|
||||
* 1. does NOT increment `consecutiveFailures`.
|
||||
* 1. does NOT increment the per-kind failure buckets.
|
||||
* 2. does NOT write an `integrations.sync.failed` audit row through
|
||||
* `recordSyncFailure`. A standalone `integrations.reauth_required`
|
||||
* row is written instead so the operations trail still shows the
|
||||
@@ -607,8 +561,8 @@ export async function resumeIntegrationFromPark(
|
||||
* page fires.
|
||||
*
|
||||
* Idempotent: a second call for the same scope-skip leaves the row at
|
||||
* `error_reauth` with the same encrypted message and the same counter
|
||||
* value (no increment). Use it from sync routines that have detected a
|
||||
* `error_reauth` with the same encrypted message and the same bucket
|
||||
* values (no increment). Use it from sync routines that have detected a
|
||||
* deliberate, structural scope gap (e.g. legacy Withings connection
|
||||
* missing `user.activity`). The defence-in-depth catch-block path
|
||||
* stays on `recordSyncFailure` because a 403 reaching the catch is
|
||||
@@ -646,17 +600,18 @@ export async function parkIntegrationAtReauth(opts: {
|
||||
state: "error_reauth",
|
||||
lastError: encryptedError,
|
||||
lastAttemptAt: now,
|
||||
// First-ever row for this (user, integration) — counter stays at
|
||||
// 0 so a later genuine transient burst still has the full
|
||||
// First-ever row for this (user, integration) — buckets stay at
|
||||
// zero so a later genuine transient burst still has the full
|
||||
// 3-strike runway before paging.
|
||||
consecutiveFailures: 0,
|
||||
consecutiveFailuresByKind: zeroBuckets(),
|
||||
},
|
||||
update: {
|
||||
state: "error_reauth",
|
||||
lastError: encryptedError,
|
||||
lastAttemptAt: now,
|
||||
// Deliberately omit `consecutiveFailures` — the existing value
|
||||
// is preserved exactly. This is the whole point of the helper.
|
||||
// Deliberately omit `consecutiveFailuresByKind` — the existing
|
||||
// bucket values are preserved exactly. This is the whole point
|
||||
// of the helper.
|
||||
},
|
||||
});
|
||||
|
||||
@@ -679,6 +634,11 @@ export async function markReauthRequired(
|
||||
integration: IntegrationKey,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
// v1.4.47 W1 — the legacy `consecutiveFailures` column was dropped
|
||||
// (migration 0076). 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.
|
||||
await prisma.integrationStatus.upsert({
|
||||
where: { userId_integration: { userId, integration } },
|
||||
create: {
|
||||
@@ -686,7 +646,11 @@ export async function markReauthRequired(
|
||||
integration,
|
||||
state: "error_reauth",
|
||||
lastError: safeEncryptError(message),
|
||||
consecutiveFailures: 1,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 0,
|
||||
reauth_required: 1,
|
||||
persistent: 0,
|
||||
},
|
||||
lastAttemptAt: new Date(),
|
||||
},
|
||||
update: {
|
||||
@@ -721,7 +685,6 @@ export async function markDisconnected(
|
||||
update: {
|
||||
state: "disconnected",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
consecutiveFailuresByKind: zeroBuckets(),
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
@@ -749,7 +712,6 @@ export async function markReconnected(
|
||||
update: {
|
||||
state: "connected",
|
||||
lastError: null,
|
||||
consecutiveFailures: 0,
|
||||
consecutiveFailuresByKind: zeroBuckets(),
|
||||
persistentFailureStartedAt: null,
|
||||
alertedAt: null,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Unit coverage for the Withings OAuth state ledger sweep.
|
||||
*
|
||||
* The cron handler in `reminder-worker.ts` is a thin wrapper around
|
||||
* `cleanupExpiredWithingsOAuthStates(p)`; the meaningful contract
|
||||
* (deletes rows whose `expiresAt < now`, returns the deleted count,
|
||||
* leaves live rows untouched) lives on the helper. These cases pin
|
||||
* the contract without going near pg-boss or a real Postgres.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
import { cleanupExpiredWithingsOAuthStates } from "../withings-oauth-state-cleanup";
|
||||
|
||||
function makePrismaMock(deletedCount: number) {
|
||||
return {
|
||||
withingsOAuthState: {
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: deletedCount }),
|
||||
},
|
||||
} as unknown as PrismaClient;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("cleanupExpiredWithingsOAuthStates", () => {
|
||||
it("issues a single `expiresAt < now` deleteMany and returns the deleted count", async () => {
|
||||
const prisma = makePrismaMock(7);
|
||||
const now = new Date("2026-05-22T03:20:00Z");
|
||||
|
||||
const deleted = await cleanupExpiredWithingsOAuthStates(prisma, now);
|
||||
|
||||
expect(deleted).toBe(7);
|
||||
expect(prisma.withingsOAuthState.deleteMany).toHaveBeenCalledTimes(1);
|
||||
expect(prisma.withingsOAuthState.deleteMany).toHaveBeenCalledWith({
|
||||
where: { expiresAt: { lt: now } },
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults `now` to a fresh wall-clock when the caller omits it", async () => {
|
||||
const prisma = makePrismaMock(0);
|
||||
const before = Date.now();
|
||||
|
||||
await cleanupExpiredWithingsOAuthStates(prisma);
|
||||
|
||||
const after = Date.now();
|
||||
const call = (prisma.withingsOAuthState.deleteMany as ReturnType<typeof vi.fn>)
|
||||
.mock.calls[0][0];
|
||||
const cutoff: Date = call.where.expiresAt.lt;
|
||||
|
||||
expect(cutoff).toBeInstanceOf(Date);
|
||||
expect(cutoff.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(cutoff.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("returns 0 when no rows match (idempotent re-run)", async () => {
|
||||
const prisma = makePrismaMock(0);
|
||||
const now = new Date("2026-05-22T03:20:00Z");
|
||||
|
||||
const deleted = await cleanupExpiredWithingsOAuthStates(prisma, now);
|
||||
|
||||
expect(deleted).toBe(0);
|
||||
expect(prisma.withingsOAuthState.deleteMany).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
import { setGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { cleanupExpiredIdempotencyKeys } from "@/lib/jobs/idempotency-cleanup";
|
||||
import { cleanupOldAuditLogs } from "@/lib/jobs/audit-log-cleanup";
|
||||
import { cleanupExpiredWithingsOAuthStates } from "@/lib/jobs/withings-oauth-state-cleanup";
|
||||
import { runHostMetricTick } from "@/lib/jobs/host-metric-sampler";
|
||||
import { aggregateRecommendationFeedback } from "@/lib/jobs/feedback-aggregator";
|
||||
import {
|
||||
@@ -177,6 +178,14 @@ const IDEMPOTENCY_CLEANUP_QUEUE = "idempotency-cleanup";
|
||||
const IDEMPOTENCY_CLEANUP_CRON = "0 3 * * *"; // daily at 03:00 (Europe/Berlin)
|
||||
const AUDIT_LOG_CLEANUP_QUEUE = "audit-log-cleanup";
|
||||
const AUDIT_LOG_CLEANUP_CRON = "15 3 * * *"; // daily at 03:15 (Europe/Berlin)
|
||||
// v1.4.47 W6 — daily sweep for the Withings OAuth state ledger. Slots
|
||||
// at :20 between the audit-log cleanup (:15) and the mood-reminder
|
||||
// cleanup (:25), inside the existing 02:xx-03:xx maintenance window.
|
||||
// Rows are normally consumed by the callback handler in single-use
|
||||
// fashion; this sweep picks up the long tail where a user closed the
|
||||
// Withings approval tab without bouncing back to the callback URL.
|
||||
const WITHINGS_OAUTH_STATE_CLEANUP_QUEUE = "withings-oauth-state-cleanup";
|
||||
const WITHINGS_OAUTH_STATE_CLEANUP_CRON = "20 3 * * *";
|
||||
const OFFHOST_BACKUP_QUEUE = "data-backup-offhost";
|
||||
// 02:30 Europe/Berlin — runs after audit-log/idempotency cleanups so old
|
||||
// rows are gone before they're snapshotted, but before the existing
|
||||
@@ -303,6 +312,10 @@ interface AuditLogCleanupPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
interface WithingsOAuthStateCleanupPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
|
||||
interface OffhostBackupPayload {
|
||||
triggeredAt: string;
|
||||
}
|
||||
@@ -1245,6 +1258,23 @@ async function handleAuditLogCleanup(jobs: Job<AuditLogCleanupPayload>[]) {
|
||||
});
|
||||
}
|
||||
|
||||
async function handleWithingsOAuthStateCleanup(
|
||||
jobs: Job<WithingsOAuthStateCleanupPayload>[],
|
||||
) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.withings_oauth_state_cleanup", async (evt) => {
|
||||
const p = getWorkerPrisma();
|
||||
try {
|
||||
const deleted = await cleanupExpiredWithingsOAuthStates(p);
|
||||
evt.addMeta("withings_oauth_state_cleanup_deleted", deleted);
|
||||
} catch (err) {
|
||||
// The OAuth flow tolerates a stale row sticking around for an
|
||||
// extra day — log + carry on so the boss queue doesn't retry-loop.
|
||||
evt.addWarning(`withings-oauth-state-cleanup failed: ${err}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function handleHostMetricSample(jobs: Job<HostMetricSamplePayload>[]) {
|
||||
void jobs;
|
||||
await withBackgroundEvent("job.host_metric_sample", async (evt) => {
|
||||
@@ -1671,6 +1701,11 @@ export async function startReminderWorker() {
|
||||
RATE_LIMIT_CLEANUP_QUEUE,
|
||||
IDEMPOTENCY_CLEANUP_QUEUE,
|
||||
AUDIT_LOG_CLEANUP_QUEUE,
|
||||
// v1.4.47 W6 — daily sweep for the Withings OAuth state ledger.
|
||||
// Same pg-boss v12 createQueue contract as the other cleanup
|
||||
// crons; without this entry the schedule below silently no-ops
|
||||
// and abandoned rows pile up.
|
||||
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
HOST_METRIC_QUEUE,
|
||||
FEEDBACK_AGGREGATOR_QUEUE,
|
||||
@@ -1744,6 +1779,7 @@ export async function startReminderWorker() {
|
||||
[RATE_LIMIT_CLEANUP_QUEUE, RATE_LIMIT_CLEANUP_CRON],
|
||||
[IDEMPOTENCY_CLEANUP_QUEUE, IDEMPOTENCY_CLEANUP_CRON],
|
||||
[AUDIT_LOG_CLEANUP_QUEUE, AUDIT_LOG_CLEANUP_CRON],
|
||||
[WITHINGS_OAUTH_STATE_CLEANUP_QUEUE, WITHINGS_OAUTH_STATE_CLEANUP_CRON],
|
||||
[OFFHOST_BACKUP_QUEUE, OFFHOST_BACKUP_CRON],
|
||||
[HOST_METRIC_QUEUE, HOST_METRIC_CRON],
|
||||
[FEEDBACK_AGGREGATOR_QUEUE, FEEDBACK_AGGREGATOR_CRON],
|
||||
@@ -1860,6 +1896,11 @@ export async function startReminderWorker() {
|
||||
{ localConcurrency: 1 },
|
||||
handleAuditLogCleanup,
|
||||
);
|
||||
await boss.work<WithingsOAuthStateCleanupPayload>(
|
||||
WITHINGS_OAUTH_STATE_CLEANUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
handleWithingsOAuthStateCleanup,
|
||||
);
|
||||
await boss.work<OffhostBackupPayload>(
|
||||
OFFHOST_BACKUP_QUEUE,
|
||||
{ localConcurrency: 1 },
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Daily cleanup for the `withings_oauth_states` table.
|
||||
*
|
||||
* The v1.4.47 W6 OAuth state ledger is single-use — every happy-path
|
||||
* + error-path branch in `withings/callback` consumes its row, so an
|
||||
* abandoned row only lingers when the user closed the Withings
|
||||
* approval tab without ever bouncing back to the callback URL. The
|
||||
* cookie + row TTL is 10 minutes, so once the timestamp blows the
|
||||
* row is dead weight on the table.
|
||||
*
|
||||
* Runs daily at 03:20 Europe/Berlin (slotted between the audit-log
|
||||
* cleanup at 03:15 and the mood-reminder cleanup at 03:25) — the
|
||||
* table is bounded by `users × concurrent in-flight handshakes`, so
|
||||
* a daily sweep is overkill on volume but cheap and keeps the
|
||||
* `expires_at` index from accumulating dead tuples.
|
||||
*
|
||||
* The handler is idempotent: re-running it within the same window
|
||||
* matches zero rows the second time because the first pass deleted
|
||||
* everything older than `now()`.
|
||||
*/
|
||||
import type { PrismaClient } from "@/generated/prisma/client";
|
||||
|
||||
export async function cleanupExpiredWithingsOAuthStates(
|
||||
prisma: PrismaClient,
|
||||
now: Date = new Date(),
|
||||
): Promise<number> {
|
||||
const { count } = await prisma.withingsOAuthState.deleteMany({
|
||||
where: { expiresAt: { lt: now } },
|
||||
});
|
||||
return count;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Unit coverage for the Withings OAuth state mint helpers.
|
||||
*
|
||||
* The connect route reads these constants verbatim; pinning the
|
||||
* 22-char base64url shape + cookie name + TTL prevents an accidental
|
||||
* drift (e.g. someone swapping `randomBytes(16).toString("hex")` back
|
||||
* in and silently exposing a 32-char hex nonce, which would still
|
||||
* "work" but is twice the entropy footprint and breaks any reader
|
||||
* that assumes the v1.4.47 shape).
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
WITHINGS_OAUTH_STATE_COOKIE,
|
||||
WITHINGS_OAUTH_STATE_TTL_MS,
|
||||
mintWithingsOAuthStateNonce,
|
||||
} from "../oauth-state";
|
||||
|
||||
describe("Withings OAuth state constants", () => {
|
||||
it("uses the historical cookie name so an in-flight handshake survives a deploy", () => {
|
||||
expect(WITHINGS_OAUTH_STATE_COOKIE).toBe("withings_state");
|
||||
});
|
||||
|
||||
it("sets a 10-minute TTL — long enough for the consent prompt, short enough to bound the cleanup", () => {
|
||||
expect(WITHINGS_OAUTH_STATE_TTL_MS).toBe(10 * 60 * 1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("mintWithingsOAuthStateNonce", () => {
|
||||
it("returns a 22-char base64url string (16 random bytes, no padding)", () => {
|
||||
const nonce = mintWithingsOAuthStateNonce();
|
||||
expect(nonce).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||
});
|
||||
|
||||
it("returns a fresh value on every call (no collisions over a small batch)", () => {
|
||||
const nonces = new Set<string>();
|
||||
for (let i = 0; i < 100; i += 1) nonces.add(mintWithingsOAuthStateNonce());
|
||||
expect(nonces.size).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* v1.4.47 W6 — shared constants + mint helper for the Withings OAuth
|
||||
* state nonce ledger.
|
||||
*
|
||||
* The legacy implementation (v1.4.42-) built the state as
|
||||
* `${user.id}:${random16}` and persisted it on both the URL state
|
||||
* param and the `withings_state` cookie. The cookie is httpOnly +
|
||||
* Secure + sameSite:lax so XSS exfiltration is closed, but the user
|
||||
* id still leaks into request logs / network captures (the v1.4.43
|
||||
* security audit L-1 finding). The new shape decouples the two: the
|
||||
* nonce is random + opaque, and a short-lived `WithingsOAuthState`
|
||||
* row carries the `(nonce → userId)` mapping.
|
||||
*
|
||||
* The helpers live on their own module so the connect route, the
|
||||
* callback route, and the cleanup cron all read the same source of
|
||||
* truth — and the unit tests can pin the contract without importing
|
||||
* either route file.
|
||||
*/
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Cookie name carrying the in-flight state nonce. Same string as the
|
||||
* legacy `withings_state` cookie so a deploy mid-flight doesn't
|
||||
* orphan a user's open Withings tab — the cookie shape is what
|
||||
* changed (nonce-only instead of `${userId}:${nonce}`), not the
|
||||
* cookie name.
|
||||
*/
|
||||
export const WITHINGS_OAUTH_STATE_COOKIE = "withings_state" as const;
|
||||
|
||||
/**
|
||||
* 10-minute TTL on the state row. Matches the cookie `maxAge`. Long
|
||||
* enough to cover a user typing their Withings credentials and
|
||||
* approving the consent prompt; short enough that an abandoned
|
||||
* handshake doesn't leave a row stranded for the daily cleanup sweep
|
||||
* to pick up hours later.
|
||||
*/
|
||||
export const WITHINGS_OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Mint a fresh state nonce. 16 random bytes → 22 base64url chars
|
||||
* (Node's `randomBytes(16).toString("base64url")` returns exactly 22
|
||||
* characters without padding for any 16-byte input). 128 bits of
|
||||
* entropy — well past the CSRF threshold and aligned with the OAuth
|
||||
* 2.0 §10.10 recommendation of "at least 128 bits".
|
||||
*/
|
||||
export function mintWithingsOAuthStateNonce(): string {
|
||||
return randomBytes(16).toString("base64url");
|
||||
}
|
||||
@@ -84,7 +84,13 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
});
|
||||
expect(status).not.toBeNull();
|
||||
expect(status!.state).toBe("error_transient");
|
||||
expect(status!.consecutiveFailures).toBe(1);
|
||||
// v1.4.47 W1 — legacy `consecutiveFailures` column dropped; the
|
||||
// per-kind bucket is now the sole counter.
|
||||
expect(status!.consecutiveFailuresByKind).toMatchObject({
|
||||
transient: 1,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
});
|
||||
expect(status!.lastAttemptAt).toBeInstanceOf(Date);
|
||||
expect(status!.lastError).toBeTruthy();
|
||||
// Encrypted at rest — must NOT be the plaintext message.
|
||||
@@ -116,7 +122,13 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
const snapshot = await getIntegrationStatus(TEST_USER_ID, "moodlog");
|
||||
expect(snapshot.state).toBe("error_transient");
|
||||
expect(snapshot.lastError).toBe("moodLog sync HTTP 502");
|
||||
expect(snapshot.consecutiveFailures).toBe(1);
|
||||
// v1.4.47 W1 — snapshot drops the legacy `consecutiveFailures`
|
||||
// scalar; the per-kind bucket carries the count.
|
||||
expect(snapshot.consecutiveFailuresByKind).toMatchObject({
|
||||
transient: 1,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("recordSyncSuccess clears the streak, resets state, and (importantly) does NOT write an audit row", async () => {
|
||||
@@ -134,7 +146,11 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
},
|
||||
});
|
||||
expect(status!.state).toBe("connected");
|
||||
expect(status!.consecutiveFailures).toBe(0);
|
||||
expect(status!.consecutiveFailuresByKind).toMatchObject({
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
});
|
||||
expect(status!.lastError).toBeNull();
|
||||
expect(status!.lastSuccessAt).toBeInstanceOf(Date);
|
||||
|
||||
@@ -242,7 +258,11 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
});
|
||||
expect(row!.state).toBe("disconnected");
|
||||
expect(row!.lastError).toBeNull();
|
||||
expect(row!.consecutiveFailures).toBe(0);
|
||||
expect(row!.consecutiveFailuresByKind).toMatchObject({
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
persistent: 0,
|
||||
});
|
||||
});
|
||||
|
||||
// v1.4.43 W14 — end-to-end resume flow against real Postgres.
|
||||
@@ -259,7 +279,6 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
userId: TEST_USER_ID,
|
||||
integration: "withings",
|
||||
state: "parked",
|
||||
consecutiveFailures: 30,
|
||||
consecutiveFailuresByKind: {
|
||||
transient: 2,
|
||||
reauth_required: 0,
|
||||
@@ -286,7 +305,6 @@ describe("IntegrationStatus end-to-end", () => {
|
||||
});
|
||||
expect(row).not.toBeNull();
|
||||
expect(row!.state).toBe("connected");
|
||||
expect(row!.consecutiveFailures).toBe(0);
|
||||
expect(row!.consecutiveFailuresByKind).toEqual({
|
||||
transient: 0,
|
||||
reauth_required: 0,
|
||||
|
||||
@@ -69,6 +69,7 @@ export async function truncateAllTables(client: PrismaClient): Promise<void> {
|
||||
"user_achievements",
|
||||
"users",
|
||||
"withings_connections",
|
||||
"withings_oauth_states",
|
||||
"workout_routes",
|
||||
"workouts",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* v1.4.47 W6 — end-to-end coverage for the new Withings OAuth state
|
||||
* ledger.
|
||||
*
|
||||
* The audit's L-1 recommendation was a fully-random nonce backed by a
|
||||
* `(nonce → userId)` ledger row, single-use semantics, with the
|
||||
* cookie carrying only the opaque nonce. This file pins the
|
||||
* connect → callback handshake end-to-end against real Postgres:
|
||||
*
|
||||
* 1. `withings/connect` mints a row and stamps the cookie.
|
||||
* 2. `withings/callback` resolves the user via the row's `userId`,
|
||||
* verifies it matches the session, and deletes the row.
|
||||
* 3. A second callback with the same nonce hits the "row not
|
||||
* found" branch and redirects to the error page (replay
|
||||
* protection).
|
||||
* 4. A callback whose row has expired hits the same branch.
|
||||
* 5. The connect handler refuses to start when the user has no
|
||||
* Withings credentials — no ledger row is minted.
|
||||
*
|
||||
* Sibling assertions in `withings-oauth.test.ts` already cover the
|
||||
* token-exchange happy path + the `disconnect` route; this file
|
||||
* focuses on the ledger lifecycle the audit flagged.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const TEST_USER_ID = "user-withings-oauth-flow";
|
||||
|
||||
const WITHINGS_OAUTH_URL =
|
||||
"https://account.withings.com/oauth2_user/authorize2";
|
||||
const WITHINGS_TOKEN_URL = "https://wbsapi.withings.net/v2/oauth2";
|
||||
const WITHINGS_NOTIFY_URL = "https://wbsapi.withings.net/notify";
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
|
||||
const { encrypt } = await import("@/lib/crypto");
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: TEST_USER_ID,
|
||||
username: "withings-flow-user",
|
||||
email: "withings-flow@example.test",
|
||||
role: "USER",
|
||||
withingsClientIdEncrypted: encrypt("client-id-flow"),
|
||||
withingsClientSecretEncrypted: encrypt("client-secret-flow"),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: {
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
|
||||
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("Withings OAuth nonce ledger (real Postgres)", () => {
|
||||
it("connect mints exactly one row keyed on the URL state, with `userId` + 10-min TTL", async () => {
|
||||
const { GET } = await import("@/app/api/withings/connect/route");
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/withings/connect"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
const location = res.headers.get("location")!;
|
||||
expect(location).toContain(WITHINGS_OAUTH_URL);
|
||||
const target = new URL(location);
|
||||
const state = target.searchParams.get("state")!;
|
||||
|
||||
// 22-char base64url. The legacy `${userId}:${random16}` shape no
|
||||
// longer reaches the URL — the audit L-1 fix.
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||
expect(state).not.toContain(":");
|
||||
expect(state).not.toContain(TEST_USER_ID);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const rows = await prisma.withingsOAuthState.findMany();
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].nonce).toBe(state);
|
||||
expect(rows[0].userId).toBe(TEST_USER_ID);
|
||||
// TTL is 10 minutes; allow a generous floor so a slow CI box
|
||||
// doesn't flake on the upper bound.
|
||||
const ttlMs = rows[0].expiresAt.getTime() - Date.now();
|
||||
expect(ttlMs).toBeGreaterThan(9 * 60 * 1000);
|
||||
expect(ttlMs).toBeLessThanOrEqual(10 * 60 * 1000 + 5_000);
|
||||
|
||||
// Cookie carries the same opaque nonce — no user id.
|
||||
const setCookie = res.headers.get("set-cookie")!;
|
||||
expect(setCookie).toContain(`withings_state=${state}`);
|
||||
expect(setCookie).not.toContain(TEST_USER_ID);
|
||||
});
|
||||
|
||||
it("connect refuses (400) when the user has no Withings credentials — no ledger row minted", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.update({
|
||||
where: { id: TEST_USER_ID },
|
||||
data: {
|
||||
withingsClientIdEncrypted: null,
|
||||
withingsClientSecretEncrypted: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { GET } = await import("@/app/api/withings/connect/route");
|
||||
const res = await (GET as unknown as (req: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/withings/connect"),
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
const rows = await prisma.withingsOAuthState.findMany();
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("connect → callback consumes the row (single-use); replay of the same nonce hits the error page", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||
const url = typeof input === "string" ? input : (input as Request).url;
|
||||
if (url === WITHINGS_TOKEN_URL) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: 0,
|
||||
body: {
|
||||
userid: "withings-userid-flow",
|
||||
access_token: "access-flow",
|
||||
refresh_token: "refresh-flow",
|
||||
expires_in: 3600,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}
|
||||
if (url === WITHINGS_NOTIFY_URL) {
|
||||
return new Response(JSON.stringify({ status: 0 }), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected fetch in flow test: ${url}`);
|
||||
});
|
||||
|
||||
// Leg 1 — connect
|
||||
const { GET: connect } = await import("@/app/api/withings/connect/route");
|
||||
const connectRes = await (
|
||||
connect as unknown as (req: Request) => Promise<Response>
|
||||
)(new Request("http://localhost/api/withings/connect"));
|
||||
const location = connectRes.headers.get("location")!;
|
||||
const nonce = new URL(location).searchParams.get("state")!;
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
const beforeCallback = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce },
|
||||
});
|
||||
expect(beforeCallback).not.toBeNull();
|
||||
|
||||
// Leg 2 — callback (happy path)
|
||||
const { GET: callback } = await import("@/app/api/withings/callback/route");
|
||||
const callbackReq = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const callbackRes = await callback(callbackReq);
|
||||
expect(callbackRes.status).toBe(307);
|
||||
expect(callbackRes.headers.get("location")).toContain(
|
||||
"withings=connected",
|
||||
);
|
||||
|
||||
const afterCallback = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce },
|
||||
});
|
||||
expect(afterCallback).toBeNull();
|
||||
|
||||
// Leg 3 — replay the same nonce. The cookie compare still passes
|
||||
// (matching strings) but the row lookup misses, so the handler
|
||||
// bounces to the error page without hitting the token endpoint
|
||||
// again.
|
||||
const fetchSpy = vi.mocked(globalThis.fetch);
|
||||
fetchSpy.mockClear();
|
||||
const replayReq = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const replayRes = await callback(replayReq);
|
||||
expect(replayRes.status).toBe(307);
|
||||
expect(replayRes.headers.get("location")).toContain(
|
||||
"withings=error&reason=state",
|
||||
);
|
||||
// No token-endpoint call on the replay leg.
|
||||
const replayTokenCalls = fetchSpy.mock.calls.filter(
|
||||
([input]) =>
|
||||
(typeof input === "string" ? input : (input as Request).url) ===
|
||||
WITHINGS_TOKEN_URL,
|
||||
);
|
||||
expect(replayTokenCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("callback rejects an expired row, deletes it, and never reaches the token exchange", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("must not be called", { status: 500 }));
|
||||
|
||||
const nonce = "expired-nonce-22charXX";
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() - 60_000), // already expired
|
||||
},
|
||||
});
|
||||
|
||||
const { GET: callback } = await import("@/app/api/withings/callback/route");
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const res = await callback(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=state",
|
||||
);
|
||||
|
||||
// Token endpoint never hit.
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The handler stamps out the expired row so a clock-skewed replay
|
||||
// can't slip through.
|
||||
const lingering = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce },
|
||||
});
|
||||
expect(lingering).toBeNull();
|
||||
});
|
||||
|
||||
it("callback rejects a row whose userId does not match the session, deletes it, and writes no connection", async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("must not be called", { status: 500 }));
|
||||
|
||||
// Seed a second user + a ledger row that points to *them*. The
|
||||
// callback runs under TEST_USER_ID's session, so the row's userId
|
||||
// mismatch must trip the rejection branch.
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: "user-other-flow",
|
||||
username: "other-user",
|
||||
email: "other@example.test",
|
||||
role: "USER",
|
||||
},
|
||||
});
|
||||
const nonce = "cross-user-noncexxxxxx";
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: "user-other-flow",
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const { GET: callback } = await import("@/app/api/withings/callback/route");
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code&state=${encodeURIComponent(nonce)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: `healthlog_session=${cookieJar.get("healthlog_session")}; withings_state=${nonce}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const res = await callback(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
expect(res.headers.get("location")).toContain(
|
||||
"withings=error&reason=state",
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The row is stamped out — single-use + cross-user mismatch is a
|
||||
// CSRF signal, not a recoverable state, so we don't leave the
|
||||
// row around for a retry.
|
||||
const lingering = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce },
|
||||
});
|
||||
expect(lingering).toBeNull();
|
||||
|
||||
// No connection landed under EITHER user.
|
||||
expect(
|
||||
await prisma.withingsConnection.findUnique({
|
||||
where: { userId: TEST_USER_ID },
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await prisma.withingsConnection.findUnique({
|
||||
where: { userId: "user-other-flow" },
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -118,15 +118,29 @@ describe("Withings OAuth handshake (real Postgres)", () => {
|
||||
);
|
||||
expect(target.searchParams.get("scope")).toBe("user.metrics,user.activity");
|
||||
const state = target.searchParams.get("state");
|
||||
expect(state).toMatch(/^user-withings-oauth:[a-f0-9]{32}$/);
|
||||
// v1.4.47 W6 — state is a fully-random 22-char base64url nonce.
|
||||
// The legacy `${user.id}:${random16}` shape leaked the user id to
|
||||
// request logs / network captures (audit L-1); the new shape is
|
||||
// opaque and resolves the user via the `WithingsOAuthState` row.
|
||||
expect(state).toMatch(/^[A-Za-z0-9_-]{22}$/);
|
||||
|
||||
// The Set-Cookie header carries the same state nonce — that cookie
|
||||
// is what `callback/route.ts` compares against on the second leg.
|
||||
// Cookie value is URL-encoded for the `:` separator; compare on the
|
||||
// encoded form so the assertion is byte-stable.
|
||||
// The Set-Cookie header carries the same opaque nonce — that
|
||||
// cookie is what `callback/route.ts` compares against on the
|
||||
// second leg before resolving the user via the ledger row.
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
expect(setCookie).toContain("withings_state=");
|
||||
expect(setCookie).toContain(encodeURIComponent(state!));
|
||||
expect(setCookie).toContain(state!);
|
||||
|
||||
// v1.4.47 W6 — the connect handler also persists a ledger row.
|
||||
// Asserting on it here closes the contract loop: the row is what
|
||||
// the callback handler will look up.
|
||||
const prisma = getPrismaClient();
|
||||
const row = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce: state! },
|
||||
});
|
||||
expect(row).not.toBeNull();
|
||||
expect(row?.userId).toBe(TEST_USER_ID);
|
||||
expect(row!.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it("callback exchanges code → encrypted tokens on the WithingsConnection row", async () => {
|
||||
@@ -156,7 +170,19 @@ describe("Withings OAuth handshake (real Postgres)", () => {
|
||||
throw new Error(`Unexpected fetch in withings-oauth test: ${url}`);
|
||||
});
|
||||
|
||||
const state = `${TEST_USER_ID}:${"a".repeat(32)}`;
|
||||
// v1.4.47 W6 — seed a live ledger row so the callback can resolve
|
||||
// the user via the row's `userId`. The state on the URL + cookie
|
||||
// must equal the row's `nonce`.
|
||||
const state = "abcdefghijklmnopqrstuv";
|
||||
const prisma = getPrismaClient();
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce: state,
|
||||
userId: TEST_USER_ID,
|
||||
expiresAt: new Date(Date.now() + 10 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code-123&state=${encodeURIComponent(state)}`,
|
||||
{
|
||||
@@ -175,7 +201,14 @@ describe("Withings OAuth handshake (real Postgres)", () => {
|
||||
"/settings/integrations?withings=connected",
|
||||
);
|
||||
|
||||
const prisma = getPrismaClient();
|
||||
// v1.4.47 W6 — the ledger row is consumed on the happy path
|
||||
// (single-use semantics). A replay of the same nonce would fail
|
||||
// the state-not-found branch and bounce to the error page.
|
||||
const consumed = await prisma.withingsOAuthState.findUnique({
|
||||
where: { nonce: state },
|
||||
});
|
||||
expect(consumed).toBeNull();
|
||||
|
||||
const conn = await prisma.withingsConnection.findUnique({
|
||||
where: { userId: TEST_USER_ID },
|
||||
});
|
||||
@@ -210,8 +243,12 @@ describe("Withings OAuth handshake (real Postgres)", () => {
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockResolvedValue(new Response("must not be called", { status: 500 }));
|
||||
|
||||
const validState = `${TEST_USER_ID}:${"a".repeat(32)}`;
|
||||
const replayedState = `${TEST_USER_ID}:${"b".repeat(32)}`;
|
||||
// v1.4.47 W6 — 22-char base64url nonces; the cookie value and the
|
||||
// URL state diverge so the constant-time check short-circuits on
|
||||
// the first byte mismatch. No ledger row needs to exist because
|
||||
// the request never reaches the row lookup.
|
||||
const validState = "aaaaaaaaaaaaaaaaaaaaaa";
|
||||
const replayedState = "bbbbbbbbbbbbbbbbbbbbbb";
|
||||
const req = new NextRequest(
|
||||
`http://localhost/api/withings/callback?code=auth-code-123&state=${encodeURIComponent(replayedState)}`,
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user